From 1a9806ce98a614420d63c65a67afb72b6a4af00e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:04:52 +0700 Subject: [PATCH 001/176] fix(reporting): add typed RCB scalar mutation contract (#144) --- .../Mms/MmsReportControlSemanticWrite.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs new file mode 100644 index 00000000..3410a5dd --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs @@ -0,0 +1,99 @@ +namespace AR.Iec61850.Mms; + +/// +/// Binds a well-known IEC 61850 Report Control Block scalar field to the exact MMS +/// value kind used on the wire. This is the semantic boundary between reporting +/// policy and MMS encoding: callers request an IEC field/value, not a raw MMS scalar. +/// +/// The contract intentionally covers only scalar mutation fields whose semantics are +/// stable across BRCB/URCB implementations. Bit-string and object-reference fields keep +/// their dedicated codecs. +/// +public readonly record struct MmsReportControlSemanticWrite +{ + private MmsReportControlSemanticWrite( + string attribute, + MmsDataValue value, + MmsDataKind expectedKind, + string expectedMmsType) + { + Attribute = attribute; + Value = value; + ExpectedKind = expectedKind; + ExpectedMmsType = expectedMmsType; + } + + public string Attribute { get; } + public MmsDataValue Value { get; } + public MmsDataKind ExpectedKind { get; } + public string ExpectedMmsType { get; } + + public static MmsReportControlSemanticWrite ReportEnable(bool enabled) + => Boolean("RptEna", enabled); + + public static MmsReportControlSemanticWrite GeneralInterrogation(bool requested) + => Boolean("GI", requested); + + public static MmsReportControlSemanticWrite Reservation(bool reserved) + => Boolean("Resv", reserved); + + /// + /// IEC 61850 BRCB ResvTms is mapped to MMS INTEGER (signed INT16 semantics). + /// Reservation time is semantically non-negative even though the wire type is signed. + /// + public static MmsReportControlSemanticWrite ReservationTime(int seconds) + { + if (seconds is < 0 or > short.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(seconds), + seconds, + $"BRCB ResvTms must be in the IEC INT16 non-negative range 0..{short.MaxValue} seconds."); + } + + return new MmsReportControlSemanticWrite( + "ResvTms", + MmsDataValue.Integer(seconds), + MmsDataKind.Integer, + "integer"); + } + + /// + /// Checks exact live MMS TypeSpecification compatibility without guessing or coercing. + /// This is a building block for the bounded type-aware mutation preflight. + /// + public bool IsCompatibleWith(MmsTypeSpecificationNode? liveType) + => liveType is not null && + Value.Kind == ExpectedKind && + string.Equals(liveType.MmsType, ExpectedMmsType, StringComparison.OrdinalIgnoreCase); + + private static MmsReportControlSemanticWrite Boolean(string attribute, bool value) + => new( + attribute, + MmsDataValue.Boolean(value), + MmsDataKind.Boolean, + "boolean"); +} + +public sealed partial class MmsClientSession +{ + /// + /// Normal RCB scalar mutation path. Attribute identity and wire type travel together + /// so reporting business logic cannot accidentally pair ResvTms with MMS Unsigned, + /// or a boolean RCB field with a numeric encoder. + /// + private Task WriteReportSemanticAttributeAsync( + MmsReportControlCandidate rcb, + MmsReportControlSemanticWrite write, + CancellationToken cancellationToken) + => WriteReportAttributeAsync(rcb, write.Attribute, write.Value, cancellationToken); + + /// + /// Cleanup counterpart to . + /// + private Task TryWriteReportSemanticAttributeForCleanupAsync( + MmsReportControlCandidate rcb, + MmsReportControlSemanticWrite write, + CancellationToken cancellationToken) + => TryWriteReportAttributeForCleanupAsync(rcb, write.Attribute, write.Value, cancellationToken); +} From eef0614c2efec8c21c398c4c208165ef9064a7da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:05:19 +0700 Subject: [PATCH 002/176] fix(reporting): route RCB scalar writes through semantic contract (#144) --- ...rsistentReportMonitorClientCompatibility.cs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs index b0fb63d2..2a4d50ce 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs @@ -26,10 +26,8 @@ public async Task StartPersistentReport !MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) && !MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb)) { - reservationStep = await WriteReportAttributeAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(60), + reservationStep = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(60), cancellationToken).ConfigureAwait(false); if (!reservationStep.IsSuccess) @@ -66,10 +64,8 @@ public async Task StartPersistentReport if (reservationStep?.IsSuccess == true && rcb is not null) { - var release = await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + var release = await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(release); cleanupAttempted = true; @@ -95,10 +91,8 @@ public async Task StartPersistentReport if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync( - start.Session.ReportControl, - "GI", - MmsDataValue.Boolean(true), + var gi = await WriteReportSemanticAttributeAsync( + start.Session.ReportControl, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) From 18f8ae2b38889f3fad028bf4074caffb714a6cef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:05:21 +0700 Subject: [PATCH 003/176] fix(reporting): route RCB scalar writes through semantic contract (#144) --- .../Mms/MmsClientSession.SclStaticReport.cs | 78 +++++++------------ 1 file changed, 26 insertions(+), 52 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs index 5fb5da12..a0407f28 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs @@ -107,10 +107,8 @@ public async Task StartStaticSclReportMon if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync( - rcb, - "Resv", - MmsDataValue.Boolean(true), + var reserve = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = reserve.IsSuccess; @@ -119,10 +117,8 @@ public async Task StartStaticSclReportMon warnings.Add("URCB Resv=true was not accepted; direct RptEna will still be attempted once."); } - var enable = await WriteReportAttributeAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(true), + var enable = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabled = enable.IsSuccess; @@ -131,10 +127,8 @@ public async Task StartStaticSclReportMon rcb.Buffered && rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) { - var reserveBrcb = await WriteReportAttributeAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(60), + var reserveBrcb = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(60), cancellationToken).ConfigureAwait(false); writes.Add(reserveBrcb); reservationTouched = reserveBrcb.IsSuccess; @@ -142,10 +136,8 @@ public async Task StartStaticSclReportMon if (reserveBrcb.IsSuccess) { - var retryEnable = await WriteReportAttributeAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(true), + var retryEnable = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(retryEnable); enabled = retryEnable.IsSuccess; @@ -160,15 +152,11 @@ public async Task StartStaticSclReportMon if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } @@ -196,10 +184,8 @@ public async Task StartStaticSclReportMon if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync( - rcb, - "GI", - MmsDataValue.Boolean(true), + var gi = await WriteReportSemanticAttributeAsync( + rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); @@ -208,10 +194,8 @@ public async Task StartStaticSclReportMon warnings.Add("Explicit GI=true request was rejected; initial-image startup failed closed."); UnregisterPersistentReportMonitor(monitor); - writes.Add(await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false)); enabled = false; monitor.EnabledByThisClient = false; @@ -219,15 +203,11 @@ public async Task StartStaticSclReportMon if (reservationTouched) { writes.Add(rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false)); reservationTouched = false; monitor.ReservationTouched = false; @@ -263,25 +243,19 @@ public async Task StartStaticSclReportMon if (enabled) { - writes.Add(await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false)); } if (reservationTouched) { writes.Add(rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync( - rcb, - "ResvTms", - MmsDataValue.Unsigned(0), + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync( - rcb, - "Resv", - MmsDataValue.Boolean(false), + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false)); } From 1bd88135cb93e832214f821138e03d2864e56259 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:05:24 +0700 Subject: [PATCH 004/176] fix(reporting): route RCB scalar writes through semantic contract (#144) --- .../Mms/MmsPersistentReportMonitor.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index b09f7b45..6dcb6c0a 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -233,14 +233,14 @@ public async Task StartPersistentReportMo } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = reserve.IsSuccess; if (!reserve.IsSuccess) warnings.Add("URCB Resv write failed. Continuing only if RptEna=true is accepted by the IED."); } - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -261,7 +261,7 @@ public async Task StartPersistentReportMo if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only."); @@ -363,7 +363,7 @@ public async Task StopPersistentReportMoni if (session.EnabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); success &= disable.IsSuccess; } @@ -410,8 +410,8 @@ public async Task StopPersistentReportMoni if (session.ReservationTouched) { var release = session.ReportControl.Buffered - ? await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(session.ReportControl, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(session.ReportControl, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); success &= release.IsSuccess; } @@ -509,10 +509,8 @@ private async Task ReceiveAndDispatchPersistentReportsAsync( if (giPending) { - writes.Add(await WriteReportAttributeAsync( - requestedSession.ReportControl, - "GI", - MmsDataValue.Boolean(true), + writes.Add(await WriteReportSemanticAttributeAsync( + requestedSession.ReportControl, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false)); giPending = false; continue; From 7fd2fa66392a7bcad8847b6630ca6db81136f947 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:05:42 +0700 Subject: [PATCH 005/176] test(reporting): lock RCB semantic wire types (#144) --- .../Mms/MmsReportControlSemanticWriteTests.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs new file mode 100644 index 00000000..98a38435 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs @@ -0,0 +1,82 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsReportControlSemanticWriteTests +{ + [Fact] + public void ResvTms_Uses_Signed_Mms_Integer_On_Wire() + { + var write = MmsReportControlSemanticWrite.ReservationTime(60); + + Assert.Equal("ResvTms", write.Attribute); + Assert.Equal(MmsDataKind.Integer, write.ExpectedKind); + Assert.Equal(MmsDataKind.Integer, write.Value.Kind); + Assert.Equal(60L, write.Value.Value); + + var encoded = MmsDataCodec.Encode(write.Value); + + Assert.NotEmpty(encoded); + Assert.Equal(0x85, encoded[0]); // MMS Data integer [5], never unsigned [6] (0x86). + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(60)] + [InlineData(32767)] + public void ResvTms_Accepts_NonNegative_Int16_Range(int seconds) + { + var write = MmsReportControlSemanticWrite.ReservationTime(seconds); + + Assert.Equal(MmsDataKind.Integer, write.Value.Kind); + Assert.Equal((long)seconds, write.Value.Value); + } + + [Theory] + [InlineData(-1)] + [InlineData(32768)] + public void ResvTms_Rejects_Values_Outside_NonNegative_Int16_Range(int seconds) + { + Assert.Throws( + () => MmsReportControlSemanticWrite.ReservationTime(seconds)); + } + + [Fact] + public void Boolean_Rcb_Fields_Are_Bound_To_Mms_Boolean() + { + var values = new[] + { + MmsReportControlSemanticWrite.ReportEnable(true), + MmsReportControlSemanticWrite.Reservation(true), + MmsReportControlSemanticWrite.GeneralInterrogation(true) + }; + + Assert.Equal(new[] { "RptEna", "Resv", "GI" }, values.Select(x => x.Attribute)); + Assert.All(values, write => + { + Assert.Equal(MmsDataKind.Boolean, write.ExpectedKind); + Assert.Equal(MmsDataKind.Boolean, write.Value.Kind); + Assert.Equal(true, write.Value.Value); + Assert.Equal(0x83, MmsDataCodec.Encode(write.Value)[0]); // MMS Data boolean [3]. + }); + } + + [Fact] + public void Live_Type_Compatibility_Is_Exact_And_Does_Not_Guess() + { + var integer = new MmsTypeSpecificationNode { MmsType = "integer" }; + var unsigned = new MmsTypeSpecificationNode { MmsType = "unsigned" }; + var boolean = new MmsTypeSpecificationNode { MmsType = "boolean" }; + + var resvTms = MmsReportControlSemanticWrite.ReservationTime(60); + Assert.True(resvTms.IsCompatibleWith(integer)); + Assert.False(resvTms.IsCompatibleWith(unsigned)); + Assert.False(resvTms.IsCompatibleWith(boolean)); + + var rptEna = MmsReportControlSemanticWrite.ReportEnable(true); + Assert.True(rptEna.IsCompatibleWith(boolean)); + Assert.False(rptEna.IsCompatibleWith(integer)); + Assert.False(rptEna.IsCompatibleWith(null)); + } +} From 6f66dd97311deff34c527db9798c68f0cea83a62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:08:03 +0700 Subject: [PATCH 006/176] fix(reporting): complete scalar semantic write migration (#144) --- .../Mms/MmsPersistentReportMonitorAttemptEvidence.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs index af6e046c..95472db6 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs @@ -162,10 +162,8 @@ public async Task StartPersistentReport if (enabled) { - var disable = await TryWriteReportAttributeForCleanupAsync( - rcb, - "RptEna", - MmsDataValue.Boolean(false), + var disable = await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(disable); cleanupSucceeded &= disable.IsSuccess; @@ -218,8 +216,8 @@ public async Task StartPersistentReport if (reserved) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); cleanupSteps.Add(release); cleanupSucceeded &= release.IsSuccess; } From 23a4b4518a6b89bb69f0c95a99b590d20e6bb79d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:08:06 +0700 Subject: [PATCH 007/176] fix(reporting): complete scalar semantic write migration (#144) --- src/AR.Iec61850/Mms/MmsReportLiveSession.cs | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs index 64dbbc2b..1732bc4f 100644 --- a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs +++ b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs @@ -1084,14 +1084,14 @@ public async Task RunGuardedStaticReportSessionAsy } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = true; if (!reserve.IsSuccess) warnings.Add("URCB Resv write failed. Proceeding guarded only if RptEna is accepted by the IED."); } - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -1113,7 +1113,7 @@ public async Task RunGuardedStaticReportSessionAsy if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only."); @@ -1126,7 +1126,7 @@ public async Task RunGuardedStaticReportSessionAsy { periodicGiWriter = async token => { - var step = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), token).ConfigureAwait(false); + var step = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), token).ConfigureAwait(false); return new MmsReportAttributeWriteStep { Attribute = "GI(periodic)", @@ -1158,7 +1158,7 @@ public async Task RunGuardedStaticReportSessionAsy { if (enabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(rcb, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); if (!disable.IsSuccess) verificationChecks.Add(FailCheck("after-cleanup", $"{rcb.Reference}.RptEna", "write false accepted", disable.Message, "RptEna=false cleanup write failed.")); @@ -1171,8 +1171,8 @@ public async Task RunGuardedStaticReportSessionAsy if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } } @@ -1273,7 +1273,7 @@ public async Task RunGuardedDynamicReportSessionAs } else if (!rcb.Buffered && rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) { - var reserve = await WriteReportAttributeAsync(rcb, "Resv", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var reserve = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.Reservation(true), cancellationToken).ConfigureAwait(false); writes.Add(reserve); reservationTouched = true; if (!reserve.IsSuccess) @@ -1300,7 +1300,7 @@ public async Task RunGuardedDynamicReportSessionAs rcbSnapshots.Add(afterBindSnapshot); AddRcbStateChecks(verificationChecks, afterBindSnapshot, expectedRptEna: false, expectedDataSet: plan.DataSetReference, stage: "after-bind"); - var enable = await WriteReportAttributeAsync(rcb, "RptEna", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var enable = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(true), cancellationToken).ConfigureAwait(false); writes.Add(enable); enabledByThisClient = enable.IsSuccess; if (!enable.IsSuccess) @@ -1322,7 +1322,7 @@ public async Task RunGuardedDynamicReportSessionAs if (triggerGeneralInterrogation) { - var gi = await WriteReportAttributeAsync(rcb, "GI", MmsDataValue.Boolean(true), cancellationToken).ConfigureAwait(false); + var gi = await WriteReportSemanticAttributeAsync(rcb, MmsReportControlSemanticWrite.GeneralInterrogation(true), cancellationToken).ConfigureAwait(false); writes.Add(gi); if (!gi.IsSuccess) warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only."); @@ -1336,7 +1336,7 @@ public async Task RunGuardedDynamicReportSessionAs { if (enabledByThisClient) { - var disable = await TryWriteReportAttributeForCleanupAsync(rcb, "RptEna", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + var disable = await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReportEnable(false), CancellationToken.None).ConfigureAwait(false); writes.Add(disable); if (!disable.IsSuccess) verificationChecks.Add(FailCheck("after-cleanup", $"{rcb.Reference}.RptEna", "write false accepted", disable.Message, "RptEna=false cleanup write failed.")); @@ -1360,8 +1360,8 @@ public async Task RunGuardedDynamicReportSessionAs if (reservationTouched) { var release = rcb.Buffered - ? await TryWriteReportAttributeForCleanupAsync(rcb, "ResvTms", MmsDataValue.Unsigned(0), CancellationToken.None).ConfigureAwait(false) - : await TryWriteReportAttributeForCleanupAsync(rcb, "Resv", MmsDataValue.Boolean(false), CancellationToken.None).ConfigureAwait(false); + ? await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.ReservationTime(0), CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync(rcb, MmsReportControlSemanticWrite.Reservation(false), CancellationToken.None).ConfigureAwait(false); writes.Add(release); } From 488e678c98a06388b987eb7d14129b7d9927815a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:10:08 +0700 Subject: [PATCH 008/176] fix(reporting): keep ResvTms codec semantic-neutral within INT16 (#144) --- src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs index 3410a5dd..cbf0b997 100644 --- a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs +++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs @@ -38,17 +38,18 @@ public static MmsReportControlSemanticWrite Reservation(bool reserved) => Boolean("Resv", reserved); /// - /// IEC 61850 BRCB ResvTms is mapped to MMS INTEGER (signed INT16 semantics). - /// Reservation time is semantically non-negative even though the wire type is signed. + /// IEC 61850 BRCB ResvTms is mapped to MMS INTEGER with signed INT16 wire semantics. + /// Policy about particular reservation-time values belongs above this codec; this + /// boundary validates only the standardized scalar representation and width. /// public static MmsReportControlSemanticWrite ReservationTime(int seconds) { - if (seconds is < 0 or > short.MaxValue) + if (seconds is < short.MinValue or > short.MaxValue) { throw new ArgumentOutOfRangeException( nameof(seconds), seconds, - $"BRCB ResvTms must be in the IEC INT16 non-negative range 0..{short.MaxValue} seconds."); + $"BRCB ResvTms must fit the signed IEC INT16 range {short.MinValue}..{short.MaxValue}."); } return new MmsReportControlSemanticWrite( From cebd6b27211f72a004a0fdf1430c858d5fd330c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:10:18 +0700 Subject: [PATCH 009/176] test(reporting): cover full signed ResvTms INT16 representation (#144) --- .../Mms/MmsReportControlSemanticWriteTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs index 98a38435..1d0845dc 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs @@ -21,11 +21,13 @@ public void ResvTms_Uses_Signed_Mms_Integer_On_Wire() } [Theory] + [InlineData(-32768)] + [InlineData(-1)] [InlineData(0)] [InlineData(1)] [InlineData(60)] [InlineData(32767)] - public void ResvTms_Accepts_NonNegative_Int16_Range(int seconds) + public void ResvTms_Accepts_Signed_Int16_Range(int seconds) { var write = MmsReportControlSemanticWrite.ReservationTime(seconds); @@ -34,9 +36,9 @@ public void ResvTms_Accepts_NonNegative_Int16_Range(int seconds) } [Theory] - [InlineData(-1)] + [InlineData(-32769)] [InlineData(32768)] - public void ResvTms_Rejects_Values_Outside_NonNegative_Int16_Range(int seconds) + public void ResvTms_Rejects_Values_Outside_Signed_Int16_Range(int seconds) { Assert.Throws( () => MmsReportControlSemanticWrite.ReservationTime(seconds)); From 5d301bedf5b3dbdfccd423977df13467b1079053 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:31:14 +0700 Subject: [PATCH 010/176] refactor(reporting): converge SCL and discovery on configured static activation (#144) --- .../Mms/MmsClientSession.SclStaticReport.cs | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs index a0407f28..5a5ec185 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs @@ -45,15 +45,27 @@ public static IReadOnlyList BuildBrcbEnableFa public sealed partial class MmsClientSession { /// - /// Starts a persistent monitor for a static DataSet already trusted from SCL. + /// Backward-compatible SCL entry point. Once a caller supplies an authoritative + /// ordered DataSet and configured RCB, activation is source-neutral: Discovery and + /// Open-SCL must execute the same wire lifecycle rather than maintain parallel + /// reporting implementations. + /// + public Task StartStaticSclReportMonitorAsync( + MmsReportSubscriptionPlan plan, + bool triggerGeneralInterrogation = false, + CancellationToken cancellationToken = default) + => StartConfiguredStaticReportMonitorAsync(plan, triggerGeneralInterrogation, cancellationToken); + + /// + /// Starts a persistent monitor for an authoritative configured static DataSet. /// The primary wire sequence is deliberately minimal: whole-RCB Read, optional /// URCB Resv=true, RptEna=true, then two whole-RCB verification Reads. BRCB /// ResvTms is a retry-only compatibility fallback when direct RptEna is rejected. - /// No DataSet browsing/creation and no GI are performed unless explicitly requested. - /// When explicit GI is requested, GI acceptance is part of startup success so a - /// caller cannot report an active initial-image monitor when the GI write was rejected. + /// No DataSet browsing/creation is performed here. When explicit GI is requested, + /// GI acceptance is part of startup success so a caller cannot report an active + /// initial-image monitor when the GI write was rejected. /// - public async Task StartStaticSclReportMonitorAsync( + public async Task StartConfiguredStaticReportMonitorAsync( MmsReportSubscriptionPlan plan, bool triggerGeneralInterrogation = false, CancellationToken cancellationToken = default) @@ -66,7 +78,7 @@ public async Task StartStaticSclReportMon return new MmsPersistentReportMonitorStartResult { IsSuccess = false, - Message = "Trusted-SCL static report activation requires a ready plan with selected RCB." + Message = "Configured static report activation requires a ready plan with selected RCB." }; } @@ -75,7 +87,7 @@ public async Task StartStaticSclReportMon return new MmsPersistentReportMonitorStartResult { IsSuccess = false, - Message = "Trusted-SCL static report activation never creates or mutates a dynamic DataSet." + Message = "Configured static report activation never creates or mutates a dynamic DataSet." }; } @@ -167,7 +179,7 @@ public async Task StartStaticSclReportMon WriteSteps = writes, Warnings = warnings, RcbSnapshots = snapshots, - Message = "RptEna=true was not accepted; trusted-SCL static report monitor was not started." + Message = "RptEna=true was not accepted; configured static report monitor was not started." }; } @@ -232,8 +244,8 @@ public async Task StartStaticSclReportMon Warnings = warnings, RcbSnapshots = snapshots, Message = triggerGeneralInterrogation - ? $"Trusted-SCL static report monitor armed for {rcb.Reference}; explicit one-shot GI=true was accepted after activation." - : $"Trusted-SCL static report monitor armed for {rcb.Reference}; no DataSet discovery/mutation or GI was performed." + ? $"Configured static report monitor armed for {rcb.Reference}; explicit one-shot GI=true was accepted after activation." + : $"Configured static report monitor armed for {rcb.Reference}; no DataSet discovery/mutation or GI was performed." }; } catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) @@ -265,7 +277,7 @@ public async Task StartStaticSclReportMon WriteSteps = writes, Warnings = warnings, RcbSnapshots = snapshots, - Message = $"Trusted-SCL static report activation failed: {ex.GetType().Name}: {ex.Message}" + Message = $"Configured static report activation failed: {ex.GetType().Name}: {ex.Message}" }; } } From 20c7b40a0a012f5d9cda624ed57fe973a3df7e1c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:31:23 +0700 Subject: [PATCH 011/176] test(reporting): lock source-neutral configured static entry point (#144) --- .../Mms/SclStaticReportActivationPolicyTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs index d3235ef4..e048ebc5 100644 --- a/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/SclStaticReportActivationPolicyTests.cs @@ -57,6 +57,17 @@ public void Brcb_Explicit_Reservation_Is_Retry_Only() fallback); } + [Fact] + public void ConfiguredStaticActivation_Is_SourceNeutral_PublicEntryPoint() + { + var configured = typeof(MmsClientSession).GetMethod(nameof(MmsClientSession.StartConfiguredStaticReportMonitorAsync)); + var scl = typeof(MmsClientSession).GetMethod(nameof(MmsClientSession.StartStaticSclReportMonitorAsync)); + + Assert.NotNull(configured); + Assert.NotNull(scl); + Assert.Equal(configured!.ReturnType, scl!.ReturnType); + } + [Fact] public void Urcb_Has_No_Brcb_Reservation_Fallback() { From c5989b0d6a12cd68bdc1fac338d98be4e418e849 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:32:28 +0700 Subject: [PATCH 012/176] feat(reporting): add bounded live type evidence for RCB scalar mutation (#144) --- .../Mms/MmsReportControlSemanticWrite.cs | 215 +++++++++++++++++- 1 file changed, 203 insertions(+), 12 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs index cbf0b997..6b226d95 100644 --- a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs +++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; + namespace AR.Iec61850.Mms; /// @@ -59,10 +61,6 @@ public static MmsReportControlSemanticWrite ReservationTime(int seconds) "integer"); } - /// - /// Checks exact live MMS TypeSpecification compatibility without guessing or coercing. - /// This is a building block for the bounded type-aware mutation preflight. - /// public bool IsCompatibleWith(MmsTypeSpecificationNode? liveType) => liveType is not null && Value.Kind == ExpectedKind && @@ -76,25 +74,218 @@ private static MmsReportControlSemanticWrite Boolean(string attribute, bool valu "boolean"); } +public enum MmsReportSemanticTypeEvidenceStatus +{ + NotChecked, + ExactMatch, + ExactMismatch, + Unavailable +} + +/// +/// Evidence for one concrete RCB scalar field on the current MMS association. +/// A successful live GetVariableAccessAttributes result is exact protocol evidence; +/// a failed/unsupported probe remains missing evidence and is not converted into a +/// false blocker. An exact contradiction is a positive blocker and prevents mutation. +/// +public sealed class MmsReportSemanticTypeEvidence +{ + public MmsReportSemanticTypeEvidenceStatus Status { get; init; } + public string Reference { get; init; } = string.Empty; + public string Attribute { get; init; } = string.Empty; + public string ExpectedMmsType { get; init; } = string.Empty; + public string LiveMmsType { get; init; } = string.Empty; + public string Source { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + + public bool IsExact => Status is + MmsReportSemanticTypeEvidenceStatus.ExactMatch or + MmsReportSemanticTypeEvidenceStatus.ExactMismatch; + + public bool AllowsMutation => Status != MmsReportSemanticTypeEvidenceStatus.ExactMismatch; +} + +public static class MmsReportSemanticTypePolicy +{ + public static MmsReportSemanticTypeEvidence Evaluate( + MmsReportControlSemanticWrite write, + string reference, + MmsVariableAccessAttributesResult? live) + { + if (live?.IsSuccess != true || live.TypeSpecification is null) + { + return new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.Unavailable, + Reference = reference, + Attribute = write.Attribute, + ExpectedMmsType = write.ExpectedMmsType, + LiveMmsType = live?.MmsType ?? string.Empty, + Source = live?.Source ?? "IEC semantic contract", + Message = live is null + ? "No live TypeSpecification probe was available; the IEC semantic contract remains the write authority." + : $"Live TypeSpecification was unavailable ({live.Message}); the IEC semantic contract remains the write authority." + }; + } + + var exactMatch = write.IsCompatibleWith(live.TypeSpecification); + return new MmsReportSemanticTypeEvidence + { + Status = exactMatch + ? MmsReportSemanticTypeEvidenceStatus.ExactMatch + : MmsReportSemanticTypeEvidenceStatus.ExactMismatch, + Reference = reference, + Attribute = write.Attribute, + ExpectedMmsType = write.ExpectedMmsType, + LiveMmsType = live.MmsType, + Source = live.Source, + Message = exactMatch + ? $"Live MMS TypeSpecification confirms {write.Attribute} as {live.MmsType}." + : $"Live MMS TypeSpecification reports {write.Attribute} as {live.MmsType}, conflicting with IEC semantic type {write.ExpectedMmsType}." + }; + } +} + public sealed partial class MmsClientSession { + private readonly ConcurrentDictionary _reportSemanticTypeEvidence = + new(StringComparer.OrdinalIgnoreCase); + /// - /// Normal RCB scalar mutation path. Attribute identity and wire type travel together - /// so reporting business logic cannot accidentally pair ResvTms with MMS Unsigned, - /// or a boolean RCB field with a numeric encoder. + /// Association-stable type evidence must never cross an MMS re-association. + /// Volatile RCB state is intentionally not cached here. /// - private Task WriteReportSemanticAttributeAsync( + private void ResetReportSemanticTypeEvidence() + => _reportSemanticTypeEvidence.Clear(); + + /// + /// Normal RCB scalar mutation path. The first mutation of each exact field on an + /// association performs a bounded exact GetVariableAccessAttributes check. Exact + /// matching evidence is cached for that association; missing evidence does not become + /// a false blocker, while an exact type contradiction fails closed before mutation. + /// + private async Task WriteReportSemanticAttributeAsync( MmsReportControlCandidate rcb, MmsReportControlSemanticWrite write, CancellationToken cancellationToken) - => WriteReportAttributeAsync(rcb, write.Attribute, write.Value, cancellationToken); + { + var reference = BuildReportAttributeReference(rcb, write.Attribute); + var typeEvidence = await GetReportSemanticTypeEvidenceAsync( + reference, + write, + forceRefresh: false, + cancellationToken).ConfigureAwait(false); + + if (!typeEvidence.AllowsMutation) + { + return new MmsReportAttributeWriteStep + { + Attribute = write.Attribute, + Reference = reference.ToString(), + Attempted = false, + IsSuccess = false, + TypeEvidence = typeEvidence, + Message = $"RCB write blocked before mutation: {typeEvidence.Message}" + }; + } + + var step = await WriteReportAttributeAsync( + rcb, + write.Attribute, + write.Value, + cancellationToken).ConfigureAwait(false); + + if (!step.IsSuccess && + string.Equals(step.FailureName, "type-inconsistent", StringComparison.OrdinalIgnoreCase) && + IsMmsInitiated) + { + typeEvidence = await GetReportSemanticTypeEvidenceAsync( + reference, + write, + forceRefresh: true, + cancellationToken).ConfigureAwait(false); + } + + return WithSemanticTypeEvidence(step, typeEvidence); + } /// - /// Cleanup counterpart to . + /// Cleanup never adds a new network preflight. It reuses any exact type evidence + /// already learned on this association and otherwise uses the standardized semantic + /// encoder so cleanup is not delayed by optional diagnostics. /// - private Task TryWriteReportSemanticAttributeForCleanupAsync( + private async Task TryWriteReportSemanticAttributeForCleanupAsync( MmsReportControlCandidate rcb, MmsReportControlSemanticWrite write, CancellationToken cancellationToken) - => TryWriteReportAttributeForCleanupAsync(rcb, write.Attribute, write.Value, cancellationToken); + { + var reference = BuildReportAttributeReference(rcb, write.Attribute); + _reportSemanticTypeEvidence.TryGetValue(ReportSemanticTypeCacheKey(reference), out var typeEvidence); + + var step = await TryWriteReportAttributeForCleanupAsync( + rcb, + write.Attribute, + write.Value, + cancellationToken).ConfigureAwait(false); + + return typeEvidence is null ? step : WithSemanticTypeEvidence(step, typeEvidence); + } + + private async Task GetReportSemanticTypeEvidenceAsync( + MmsObjectReference reference, + MmsReportControlSemanticWrite write, + bool forceRefresh, + CancellationToken cancellationToken) + { + var key = ReportSemanticTypeCacheKey(reference); + if (!forceRefresh && _reportSemanticTypeEvidence.TryGetValue(key, out var cached)) + return cached; + + MmsVariableAccessAttributesResult? live = null; + try + { + live = await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + live = new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = reference, + Source = "GetVariableAccessAttributes", + Message = $"Type preflight failed safely: {ex.GetType().Name}: {ex.Message}" + }; + } + + var evidence = MmsReportSemanticTypePolicy.Evaluate(write, reference.ToString(), live); + if (evidence.IsExact) + _reportSemanticTypeEvidence[key] = evidence; + + return evidence; + } + + private static MmsObjectReference BuildReportAttributeReference( + MmsReportControlCandidate rcb, + string attribute) + => MmsObjectReference.Parse($"{rcb.Reference}.{attribute}", rcb.FunctionalConstraint); + + private static string ReportSemanticTypeCacheKey(MmsObjectReference reference) + => $"{reference.Domain}/{reference.Item}"; + + private static MmsReportAttributeWriteStep WithSemanticTypeEvidence( + MmsReportAttributeWriteStep step, + MmsReportSemanticTypeEvidence typeEvidence) + => new() + { + Attribute = step.Attribute, + Reference = step.Reference, + Attempted = step.Attempted, + IsSuccess = step.IsSuccess, + FailureCode = step.FailureCode, + FailureName = step.FailureName, + TypeEvidence = typeEvidence, + Message = step.IsSuccess + ? $"{step.Message} Type evidence: {typeEvidence.Message}" + : $"{step.Message} Type evidence: {typeEvidence.Message}" + }; } From 338cd4fda4b3b715dbd91b6b6c684c178c1c0fac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:33:01 +0700 Subject: [PATCH 013/176] feat(reporting): preserve typed write failure evidence (#144) --- src/AR.Iec61850/Mms/MmsReportLiveSession.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs index 1732bc4f..350fdb7e 100644 --- a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs +++ b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs @@ -6,6 +6,9 @@ public sealed class MmsReportAttributeWriteStep public string Reference { get; init; } = string.Empty; public bool Attempted { get; init; } public bool IsSuccess { get; init; } + public int? FailureCode { get; init; } + public string FailureName { get; init; } = string.Empty; + public MmsReportSemanticTypeEvidence? TypeEvidence { get; init; } public string Message { get; init; } = string.Empty; } @@ -1917,14 +1920,17 @@ private async Task WriteReportAttributeAsync( MmsDataValue value, CancellationToken cancellationToken) { - var reference = MmsObjectReference.Parse($"{rcb.Reference}.{attribute}", rcb.FunctionalConstraint); + var reference = BuildReportAttributeReference(rcb, attribute); var result = await WriteSingleVariableAsync(reference, value, cancellationToken).ConfigureAwait(false); + var failure = result.AccessResults.FirstOrDefault(access => !access.IsSuccess); return new MmsReportAttributeWriteStep { Attribute = attribute, Reference = reference.ToString(), Attempted = true, IsSuccess = result.IsSuccess, + FailureCode = failure?.FailureCode, + FailureName = failure?.FailureName ?? string.Empty, Message = result.Message }; } @@ -1950,6 +1956,9 @@ private async Task TryWriteReportAttributeForCleanu Reference = first.Reference, Attempted = true, IsSuccess = false, + FailureCode = first.FailureCode, + FailureName = first.FailureName, + TypeEvidence = first.TypeEvidence, Message = $"cleanup reconnect failed. First attempt: {first.Message}" }; } @@ -1961,6 +1970,9 @@ private async Task TryWriteReportAttributeForCleanu Reference = retry.Reference, Attempted = true, IsSuccess = retry.IsSuccess, + FailureCode = retry.FailureCode, + FailureName = retry.FailureName, + TypeEvidence = retry.TypeEvidence, Message = retry.IsSuccess ? $"cleanup retry after reconnect succeeded. First attempt: {first.Message}" : $"cleanup retry after reconnect failed: {retry.Message}. First attempt: {first.Message}" @@ -1979,6 +1991,9 @@ private async Task TryWriteReportAttributeForCleanu Reference = retry.Reference, Attempted = true, IsSuccess = retry.IsSuccess, + FailureCode = retry.FailureCode, + FailureName = retry.FailureName, + TypeEvidence = retry.TypeEvidence, Message = retry.IsSuccess ? $"cleanup retry after reconnect succeeded. First exception: {ex.GetType().Name}: {ex.Message}" : $"cleanup retry after reconnect failed: {retry.Message}. First exception: {ex.GetType().Name}: {ex.Message}" From 794ebea3a0abd058d527e5dff8e8277f8ede27c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:33:28 +0700 Subject: [PATCH 014/176] fix(reporting): scope semantic type evidence to one association (#144) --- src/AR.Iec61850/Mms/MmsClientSession.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs index 237333a7..d84e22c5 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.cs @@ -765,6 +765,7 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation { State = MmsAssociationState.MmsInitiated; LastHandshakeMessage = result.Message; + ResetReportSemanticTypeEvidence(); _receivePump.Start(cancellationToken); return; } From c386bb87114c76a462ac692f579c2c878ce5a0d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:33:52 +0700 Subject: [PATCH 015/176] test(reporting): distinguish missing from conflicting type evidence (#144) --- .../Mms/MmsReportControlSemanticWriteTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs index 1d0845dc..0c16a681 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportControlSemanticWriteTests.cs @@ -81,4 +81,78 @@ public void Live_Type_Compatibility_Is_Exact_And_Does_Not_Guess() Assert.False(rptEna.IsCompatibleWith(integer)); Assert.False(rptEna.IsCompatibleWith(null)); } + [Fact] + public void Exact_Live_Type_Mismatch_Is_A_Positive_Blocker() + { + var write = MmsReportControlSemanticWrite.ReservationTime(60); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$ResvTms", "BR"), + TypeSpecification = new MmsTypeSpecificationNode + { + MmsType = "unsigned", + SclBType = "INT32U" + } + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.ResvTms [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.ExactMismatch, evidence.Status); + Assert.True(evidence.IsExact); + Assert.False(evidence.AllowsMutation); + Assert.Equal("integer", evidence.ExpectedMmsType); + Assert.Equal("unsigned", evidence.LiveMmsType); + } + + [Fact] + public void Missing_Live_Type_Evidence_Does_Not_Become_A_False_Blocker() + { + var write = MmsReportControlSemanticWrite.ReportEnable(true); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$RptEna", "BR"), + Message = "GetVariableAccessAttributes unsupported by this server." + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.RptEna [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.Unavailable, evidence.Status); + Assert.False(evidence.IsExact); + Assert.True(evidence.AllowsMutation); + Assert.Contains("IEC semantic contract", evidence.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Exact_Live_Type_Match_Confirms_Semantic_Mutation() + { + var write = MmsReportControlSemanticWrite.ReportEnable(true); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "LLN0$BR$Brcb01$RptEna", "BR"), + TypeSpecification = new MmsTypeSpecificationNode + { + MmsType = "boolean", + SclBType = "BOOLEAN" + } + }; + + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + "LD0/LLN0.BR.Brcb01.RptEna [BR]", + live); + + Assert.Equal(MmsReportSemanticTypeEvidenceStatus.ExactMatch, evidence.Status); + Assert.True(evidence.IsExact); + Assert.True(evidence.AllowsMutation); + } + } From 3e94749c5cd15967401961a36f410e4d5b912764 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:34:29 +0700 Subject: [PATCH 016/176] fix(reporting): preserve exact-case identity in type-evidence cache (#144) --- src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs index 6b226d95..497e9ebf 100644 --- a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs +++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs @@ -149,7 +149,7 @@ public static MmsReportSemanticTypeEvidence Evaluate( public sealed partial class MmsClientSession { private readonly ConcurrentDictionary _reportSemanticTypeEvidence = - new(StringComparer.OrdinalIgnoreCase); + new(StringComparer.Ordinal); /// /// Association-stable type evidence must never cross an MMS re-association. @@ -284,8 +284,6 @@ private static MmsReportAttributeWriteStep WithSemanticTypeEvidence( FailureCode = step.FailureCode, FailureName = step.FailureName, TypeEvidence = typeEvidence, - Message = step.IsSuccess - ? $"{step.Message} Type evidence: {typeEvidence.Message}" - : $"{step.Message} Type evidence: {typeEvidence.Message}" + Message = $"{step.Message} Type evidence: {typeEvidence.Message}" }; } From 34964f5d27e7ae9066c9bb017993270926c5307d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:36:23 +0700 Subject: [PATCH 017/176] feat(mms): classify write failures from structured DataAccessError evidence (#144) --- .../Mms/MmsInteropFailureClassification.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs diff --git a/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs b/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs new file mode 100644 index 00000000..df6b08ef --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsInteropFailureClassification.cs @@ -0,0 +1,71 @@ +namespace AR.Iec61850.Mms; + +/// +/// Protocol-level failure categories used by planning/execution code. These are derived +/// from structured MMS DataAccessError codes, never from human-readable message text. +/// +public enum MmsInteropFailureKind +{ + None, + ObjectInvalidated, + HardwareFault, + TemporarilyUnavailable, + AccessDenied, + ObjectUndefined, + InvalidAddress, + TypeUnsupported, + TypeMismatch, + AttributeInconsistent, + AccessUnsupported, + ObjectNotFound, + InvalidValue, + Unknown +} + +/// +/// Evidence-oriented recovery hint. This does not authorize an automatic retry; it tells +/// higher layers what must be revalidated before any bounded follow-up operation. +/// +public enum MmsInteropRecoveryHint +{ + None, + RevalidateType, + RevalidateRuntimeState, + RevalidateObjectIdentity, + RetryLaterAfterStateRefresh, + FailClosed +} + +public readonly record struct MmsInteropFailureClassification( + MmsInteropFailureKind Kind, + MmsInteropRecoveryHint RecoveryHint) +{ + public static MmsInteropFailureClassification Success => + new(MmsInteropFailureKind.None, MmsInteropRecoveryHint.None); +} + +public static class MmsInteropFailureClassifier +{ + public static MmsInteropFailureClassification Classify(MmsWriteAccessResult? access) + { + if (access is null || access.IsSuccess) + return MmsInteropFailureClassification.Success; + + return access.FailureCode switch + { + 0 => new(MmsInteropFailureKind.ObjectInvalidated, MmsInteropRecoveryHint.RevalidateRuntimeState), + 1 => new(MmsInteropFailureKind.HardwareFault, MmsInteropRecoveryHint.FailClosed), + 2 => new(MmsInteropFailureKind.TemporarilyUnavailable, MmsInteropRecoveryHint.RetryLaterAfterStateRefresh), + 3 => new(MmsInteropFailureKind.AccessDenied, MmsInteropRecoveryHint.FailClosed), + 4 => new(MmsInteropFailureKind.ObjectUndefined, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 5 => new(MmsInteropFailureKind.InvalidAddress, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 6 => new(MmsInteropFailureKind.TypeUnsupported, MmsInteropRecoveryHint.RevalidateType), + 7 => new(MmsInteropFailureKind.TypeMismatch, MmsInteropRecoveryHint.RevalidateType), + 8 => new(MmsInteropFailureKind.AttributeInconsistent, MmsInteropRecoveryHint.RevalidateRuntimeState), + 9 => new(MmsInteropFailureKind.AccessUnsupported, MmsInteropRecoveryHint.FailClosed), + 10 => new(MmsInteropFailureKind.ObjectNotFound, MmsInteropRecoveryHint.RevalidateObjectIdentity), + 11 => new(MmsInteropFailureKind.InvalidValue, MmsInteropRecoveryHint.FailClosed), + _ => new(MmsInteropFailureKind.Unknown, MmsInteropRecoveryHint.FailClosed) + }; + } +} From fbedf54dfa7421f69044b7efc428bc10fa738356 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:36:44 +0700 Subject: [PATCH 018/176] feat(reporting): carry structured interoperability failure evidence (#144) --- src/AR.Iec61850/Mms/MmsReportLiveSession.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs index 350fdb7e..08047c57 100644 --- a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs +++ b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs @@ -8,6 +8,8 @@ public sealed class MmsReportAttributeWriteStep public bool IsSuccess { get; init; } public int? FailureCode { get; init; } public string FailureName { get; init; } = string.Empty; + public MmsInteropFailureKind FailureKind { get; init; } = MmsInteropFailureKind.None; + public MmsInteropRecoveryHint RecoveryHint { get; init; } = MmsInteropRecoveryHint.None; public MmsReportSemanticTypeEvidence? TypeEvidence { get; init; } public string Message { get; init; } = string.Empty; } @@ -1923,6 +1925,7 @@ private async Task WriteReportAttributeAsync( var reference = BuildReportAttributeReference(rcb, attribute); var result = await WriteSingleVariableAsync(reference, value, cancellationToken).ConfigureAwait(false); var failure = result.AccessResults.FirstOrDefault(access => !access.IsSuccess); + var classification = MmsInteropFailureClassifier.Classify(failure); return new MmsReportAttributeWriteStep { Attribute = attribute, @@ -1931,6 +1934,8 @@ private async Task WriteReportAttributeAsync( IsSuccess = result.IsSuccess, FailureCode = failure?.FailureCode, FailureName = failure?.FailureName ?? string.Empty, + FailureKind = classification.Kind, + RecoveryHint = classification.RecoveryHint, Message = result.Message }; } @@ -1958,6 +1963,8 @@ private async Task TryWriteReportAttributeForCleanu IsSuccess = false, FailureCode = first.FailureCode, FailureName = first.FailureName, + FailureKind = first.FailureKind, + RecoveryHint = first.RecoveryHint, TypeEvidence = first.TypeEvidence, Message = $"cleanup reconnect failed. First attempt: {first.Message}" }; @@ -1972,6 +1979,8 @@ private async Task TryWriteReportAttributeForCleanu IsSuccess = retry.IsSuccess, FailureCode = retry.FailureCode, FailureName = retry.FailureName, + FailureKind = retry.FailureKind, + RecoveryHint = retry.RecoveryHint, TypeEvidence = retry.TypeEvidence, Message = retry.IsSuccess ? $"cleanup retry after reconnect succeeded. First attempt: {first.Message}" @@ -1993,6 +2002,8 @@ private async Task TryWriteReportAttributeForCleanu IsSuccess = retry.IsSuccess, FailureCode = retry.FailureCode, FailureName = retry.FailureName, + FailureKind = retry.FailureKind, + RecoveryHint = retry.RecoveryHint, TypeEvidence = retry.TypeEvidence, Message = retry.IsSuccess ? $"cleanup retry after reconnect succeeded. First exception: {ex.GetType().Name}: {ex.Message}" From b2014976174b87df376d7b3e67dddf1bb07c5aa3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:36:52 +0700 Subject: [PATCH 019/176] refactor(reporting): drive type recovery from structured failure classification (#144) --- src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs index 497e9ebf..e7f2002e 100644 --- a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs +++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs @@ -196,7 +196,7 @@ private async Task WriteReportSemanticAttributeAsyn cancellationToken).ConfigureAwait(false); if (!step.IsSuccess && - string.Equals(step.FailureName, "type-inconsistent", StringComparison.OrdinalIgnoreCase) && + step.FailureKind == MmsInteropFailureKind.TypeMismatch && IsMmsInitiated) { typeEvidence = await GetReportSemanticTypeEvidenceAsync( @@ -283,6 +283,8 @@ private static MmsReportAttributeWriteStep WithSemanticTypeEvidence( IsSuccess = step.IsSuccess, FailureCode = step.FailureCode, FailureName = step.FailureName, + FailureKind = step.FailureKind, + RecoveryHint = step.RecoveryHint, TypeEvidence = typeEvidence, Message = $"{step.Message} Type evidence: {typeEvidence.Message}" }; From 6b81b9ce54da8bf224f2cba6d3c25af4585b2d68 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:37:05 +0700 Subject: [PATCH 020/176] test(mms): lock structured interoperability recovery classification (#144) --- .../Mms/G11MmsWriteAccessErrorTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs b/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs index 7e42dc89..e4c3e154 100644 --- a/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/G11MmsWriteAccessErrorTests.cs @@ -47,4 +47,40 @@ public void StandardMmsDataAccessErrors_HaveExplicitNames(int code, string expec [Fact] public void VendorSpecificUnknownAccessError_RemainsNumericallyVisible() => Assert.Equal("data-access-error-99", MmsWriteResponseDecoder.NameDataAccessError(99)); + [Theory] + [InlineData(2, MmsInteropFailureKind.TemporarilyUnavailable, MmsInteropRecoveryHint.RetryLaterAfterStateRefresh)] + [InlineData(3, MmsInteropFailureKind.AccessDenied, MmsInteropRecoveryHint.FailClosed)] + [InlineData(6, MmsInteropFailureKind.TypeUnsupported, MmsInteropRecoveryHint.RevalidateType)] + [InlineData(7, MmsInteropFailureKind.TypeMismatch, MmsInteropRecoveryHint.RevalidateType)] + [InlineData(8, MmsInteropFailureKind.AttributeInconsistent, MmsInteropRecoveryHint.RevalidateRuntimeState)] + [InlineData(10, MmsInteropFailureKind.ObjectNotFound, MmsInteropRecoveryHint.RevalidateObjectIdentity)] + [InlineData(11, MmsInteropFailureKind.InvalidValue, MmsInteropRecoveryHint.FailClosed)] + public void DataAccessErrors_Map_To_Structured_Interop_Recovery( + int code, + MmsInteropFailureKind expectedKind, + MmsInteropRecoveryHint expectedRecovery) + { + var access = new MmsWriteAccessResult + { + IsSuccess = false, + FailureCode = code, + FailureName = MmsWriteResponseDecoder.NameDataAccessError(code) + }; + + var classified = MmsInteropFailureClassifier.Classify(access); + + Assert.Equal(expectedKind, classified.Kind); + Assert.Equal(expectedRecovery, classified.RecoveryHint); + } + + [Fact] + public void Successful_Access_Has_No_Recovery_Action() + { + var classified = MmsInteropFailureClassifier.Classify( + new MmsWriteAccessResult { IsSuccess = true }); + + Assert.Equal(MmsInteropFailureKind.None, classified.Kind); + Assert.Equal(MmsInteropRecoveryHint.None, classified.RecoveryHint); + } + } From 4e6b521452a5d7641ba0d9be9c4808b94d6e97cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:40:15 +0700 Subject: [PATCH 021/176] feat(canonical): add source-neutral static report coverage resolver (#144) --- .../CanonicalStaticReportCoverageResolver.cs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs new file mode 100644 index 00000000..e1c5952d --- /dev/null +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs @@ -0,0 +1,269 @@ +namespace AR.Iec61850.Engineering.Canonical; + +public enum CanonicalStaticReportSignalCoverageStatus +{ + Covered, + SignalNotFound, + AmbiguousSignalIdentity, + NoStaticDataSetMembership, + StaticDataSetWithoutReportControl +} + +public sealed class CanonicalStaticReportSelection +{ + public string Reference { get; init; } = string.Empty; + public string FunctionalConstraint { get; init; } = string.Empty; +} + +public sealed class CanonicalStaticReportDataSetCandidate +{ + public string DataSetReference { get; init; } = string.Empty; + public int[] MemberIndexes { get; init; } = Array.Empty(); + public string[] ReportControlReferences { get; init; } = Array.Empty(); +} + +public sealed class CanonicalStaticReportSignalCoverage +{ + public string RequestedReference { get; init; } = string.Empty; + public string RequestedFunctionalConstraint { get; init; } = string.Empty; + public string CanonicalReference { get; init; } = string.Empty; + public string CanonicalFunctionalConstraint { get; init; } = string.Empty; + public CanonicalStaticReportSignalCoverageStatus Status { get; init; } + public CanonicalStaticReportDataSetCandidate[] DataSetCandidates { get; init; } = Array.Empty(); + + public bool IsCovered => Status == CanonicalStaticReportSignalCoverageStatus.Covered; +} + +public sealed class CanonicalStaticReportCoverageSegment +{ + public string DataSetReference { get; init; } = string.Empty; + public CanonicalDataSetMember[] OrderedMembers { get; init; } = Array.Empty(); + public CanonicalReportControl[] ReportControls { get; init; } = Array.Empty(); + public string[] SelectedSignalReferences { get; init; } = Array.Empty(); +} + +public sealed class CanonicalStaticReportCoveragePlan +{ + public CanonicalIngressKind Ingress { get; init; } + public CanonicalStaticReportSignalCoverage[] Signals { get; init; } = Array.Empty(); + public CanonicalStaticReportCoverageSegment[] Segments { get; init; } = Array.Empty(); + + public int RequestedSignalCount => Signals.Length; + public int CoveredSignalCount => Signals.Count(signal => signal.IsCovered); + public int UncoveredSignalCount => RequestedSignalCount - CoveredSignalCount; +} + +/// +/// Source-neutral static-report coverage over the shared CanonicalIedModel. +/// +/// This resolver never chooses an operational RCB instance and performs no IED writes. +/// It answers one question only: which configured static DataSet/ReportControl resources +/// already cover each exact canonical signal? Discovery and Open-SCL therefore enter the +/// same coverage logic after canonicalization. +/// +/// Structured DataSet members are handled by exact reference-boundary containment. A +/// member such as A.phsA covers its typed descendants but never sibling phases. Full +/// ordered DataSet membership is retained in every segment because InformationReport +/// projection remains positional even when the application selected only a subset. +/// +public static class CanonicalStaticReportCoverageResolver +{ + public static CanonicalStaticReportCoveragePlan Resolve( + CanonicalIedModel model, + IEnumerable selections) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + + var requested = selections.ToArray(); + var signalIndex = BuildSignalIndex(model); + var reportByDataSet = model.ReportControls + .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference)) + .GroupBy(report => report.DataSetReference, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.OrderBy(report => report.Reference, StringComparer.Ordinal).ToArray(), + StringComparer.Ordinal); + + var results = new CanonicalStaticReportSignalCoverage[requested.Length]; + for (var index = 0; index < requested.Length; index++) + results[index] = ResolveSignal(model, requested[index], signalIndex, reportByDataSet); + + var coveredReferences = results + .Where(result => result.IsCovered) + .Select(result => result.CanonicalReference) + .ToHashSet(StringComparer.Ordinal); + + var segmentDataSets = results + .Where(result => result.IsCovered) + .SelectMany(result => result.DataSetCandidates) + .Select(candidate => candidate.DataSetReference) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + var segments = segmentDataSets + .Select(dataSetReference => BuildSegment( + model, + dataSetReference, + results, + reportByDataSet)) + .Where(segment => segment is not null) + .Cast() + .ToArray(); + + return new CanonicalStaticReportCoveragePlan + { + Ingress = model.Source.Ingress, + Signals = results, + Segments = segments + }; + } + + private static CanonicalStaticReportSignalCoverage ResolveSignal( + CanonicalIedModel model, + CanonicalStaticReportSelection selection, + IReadOnlyDictionary signalIndex, + IReadOnlyDictionary reportByDataSet) + { + var requestedReference = selection.Reference?.Trim() ?? string.Empty; + var requestedFc = NormalizeFc(selection.FunctionalConstraint); + + if (!signalIndex.TryGetValue(requestedReference, out var referenceMatches)) + { + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + Status = CanonicalStaticReportSignalCoverageStatus.SignalNotFound + }; + } + + var matches = string.IsNullOrWhiteSpace(requestedFc) + ? referenceMatches + : referenceMatches + .Where(signal => string.Equals( + NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint)), + requestedFc, + StringComparison.Ordinal)) + .ToArray(); + + if (matches.Length != 1) + { + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + Status = matches.Length == 0 + ? CanonicalStaticReportSignalCoverageStatus.SignalNotFound + : CanonicalStaticReportSignalCoverageStatus.AmbiguousSignalIdentity + }; + } + + var signal = matches[0]; + var canonicalReference = model.Strings.Resolve(signal.ObjectReference); + var canonicalFc = NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint)); + + var candidates = new List(); + var anyDataSet = false; + foreach (var dataSet in model.DataSets) + { + var memberIndexes = dataSet.Members + .Where(member => MemberCoversSignal(member, canonicalReference, canonicalFc)) + .Select(member => member.Index) + .OrderBy(memberIndex => memberIndex) + .ToArray(); + if (memberIndexes.Length == 0) + continue; + + anyDataSet = true; + reportByDataSet.TryGetValue(dataSet.Reference, out var reports); + candidates.Add(new CanonicalStaticReportDataSetCandidate + { + DataSetReference = dataSet.Reference, + MemberIndexes = memberIndexes, + ReportControlReferences = reports?.Select(report => report.Reference).ToArray() ?? Array.Empty() + }); + } + + var reportBacked = candidates + .Where(candidate => candidate.ReportControlReferences.Length > 0) + .OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal) + .ToArray(); + + return new CanonicalStaticReportSignalCoverage + { + RequestedReference = requestedReference, + RequestedFunctionalConstraint = requestedFc, + CanonicalReference = canonicalReference, + CanonicalFunctionalConstraint = canonicalFc, + Status = reportBacked.Length > 0 + ? CanonicalStaticReportSignalCoverageStatus.Covered + : anyDataSet + ? CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl + : CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership, + DataSetCandidates = reportBacked.Length > 0 + ? reportBacked + : candidates.OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal).ToArray() + }; + } + + private static CanonicalStaticReportCoverageSegment? BuildSegment( + CanonicalIedModel model, + string dataSetReference, + IReadOnlyList results, + IReadOnlyDictionary reportByDataSet) + { + var dataSet = model.DataSets.SingleOrDefault(candidate => + string.Equals(candidate.Reference, dataSetReference, StringComparison.Ordinal)); + if (dataSet is null) + return null; + + reportByDataSet.TryGetValue(dataSetReference, out var reports); + reports ??= Array.Empty(); + + var selected = results + .Where(result => result.IsCovered && + result.DataSetCandidates.Any(candidate => + string.Equals(candidate.DataSetReference, dataSetReference, StringComparison.Ordinal))) + .Select(result => result.CanonicalReference) + .Distinct(StringComparer.Ordinal) + .OrderBy(reference => reference, StringComparer.Ordinal) + .ToArray(); + + return new CanonicalStaticReportCoverageSegment + { + DataSetReference = dataSetReference, + OrderedMembers = dataSet.Members.OrderBy(member => member.Index).ToArray(), + ReportControls = reports, + SelectedSignalReferences = selected + }; + } + + private static IReadOnlyDictionary BuildSignalIndex(CanonicalIedModel model) + => model.Signals + .GroupBy(signal => model.Strings.Resolve(signal.ObjectReference), StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + + private static bool MemberCoversSignal( + CanonicalDataSetMember member, + string signalReference, + string signalFc) + { + var memberReference = member.Reference?.Trim() ?? string.Empty; + if (memberReference.Length == 0 || signalReference.Length == 0) + return false; + + var memberFc = NormalizeFc(member.FunctionalConstraint); + if (memberFc.Length > 0 && + !string.Equals(memberFc, signalFc, StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(signalReference, memberReference, StringComparison.Ordinal) || + signalReference.StartsWith(memberReference + ".", StringComparison.Ordinal); + } + + private static string NormalizeFc(string? value) + => (value ?? string.Empty).Trim().ToUpperInvariant(); +} From 13b84fe243578d461a5edf43cd01361db5e76a90 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:40:49 +0700 Subject: [PATCH 022/176] test(canonical): prove source-neutral static coverage and ordered DataSet authority (#144) --- ...onicalStaticReportCoverageResolverTests.cs | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs new file mode 100644 index 00000000..0956e762 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs @@ -0,0 +1,292 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Tests.Engineering; + +public sealed class CanonicalStaticReportCoverageResolverTests +{ + [Fact] + public void Discovery_And_Scl_Ingress_Use_The_Same_Static_Coverage_Contract() + { + var discovery = BuildCanonical(); + var scl = WithIngress(discovery, CanonicalIngressKind.SclFile); + + var selection = new[] + { + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + }; + + var fromDiscovery = CanonicalStaticReportCoverageResolver.Resolve(discovery, selection); + var fromScl = CanonicalStaticReportCoverageResolver.Resolve(scl, selection); + + Assert.Equal(CanonicalIngressKind.LiveMmsDiscovery, fromDiscovery.Ingress); + Assert.Equal(CanonicalIngressKind.SclFile, fromScl.Ingress); + Assert.Equal(fromDiscovery.CoveredSignalCount, fromScl.CoveredSignalCount); + Assert.Equal( + fromDiscovery.Signals[0].DataSetCandidates.Select(candidate => candidate.DataSetReference), + fromScl.Signals[0].DataSetCandidates.Select(candidate => candidate.DataSetReference)); + Assert.Equal( + fromDiscovery.Signals[0].DataSetCandidates[0].ReportControlReferences, + fromScl.Signals[0].DataSetCandidates[0].ReportControlReferences); + } + + [Fact] + public void Structured_Member_Covers_Only_Exact_Boundary_Descendants() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + }, + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsB.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal(CanonicalStaticReportSignalCoverageStatus.Covered, plan.Signals[0].Status); + Assert.Equal([0], plan.Signals[0].DataSetCandidates[0].MemberIndexes); + Assert.Equal( + CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership, + plan.Signals[1].Status); + } + + [Fact] + public void Segment_Preserves_Full_Ordered_DataSet_And_All_Configured_Rcbs() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + var segment = Assert.Single(plan.Segments); + Assert.Equal("IEDALD0/LLN0.Analog", segment.DataSetReference); + Assert.Equal(2, segment.OrderedMembers.Length); + Assert.Equal(0, segment.OrderedMembers[0].Index); + Assert.Equal(1, segment.OrderedMembers[1].Index); + Assert.Equal( + new[] + { + "IEDALD0/LLN0.BR.Buffer01", + "IEDALD0/LLN0.RP.Unbuffer01" + }, + segment.ReportControls.Select(report => report.Reference)); + Assert.Equal( + ["IEDALD0/MMXU1.A.phsA.cVal.mag.f"], + segment.SelectedSignalReferences); + } + + [Fact] + public void Canonical_Identity_Remains_Exact_Case() + { + var model = BuildCanonical(); + var plan = CanonicalStaticReportCoverageResolver.Resolve( + model, + [ + new CanonicalStaticReportSelection + { + Reference = "iedald0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal(CanonicalStaticReportSignalCoverageStatus.SignalNotFound, plan.Signals[0].Status); + Assert.Equal(0, plan.CoveredSignalCount); + } + + [Fact] + public void DataSet_Without_Rcb_Is_Not_Promoted_To_Report_Coverage() + { + var model = BuildCanonical(); + var withoutReports = new CanonicalIedModel + { + SchemaVersion = model.SchemaVersion, + GeneratedAtUtc = model.GeneratedAtUtc, + Identity = model.Identity, + Communication = model.Communication, + Source = model.Source, + Strings = model.Strings, + AccessPoints = model.AccessPoints, + LogicalDevices = model.LogicalDevices, + LogicalNodes = model.LogicalNodes, + DataObjects = model.DataObjects, + Signals = model.Signals, + DataSets = model.DataSets, + ReportControls = Array.Empty(), + Diagnostics = model.Diagnostics + }; + + var plan = CanonicalStaticReportCoverageResolver.Resolve( + withoutReports, + [ + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + } + ]); + + Assert.Equal( + CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl, + plan.Signals[0].Status); + Assert.Empty(plan.Segments); + } + + private static CanonicalIedModel BuildCanonical() + { + var live = new LiveIedModelDiscoveryDocument + { + Source = "LiveMmsDiscovery", + IedName = "IEDA", + IedIdentity = new LiveIedIdentity + { + IedName = "IEDA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }, + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDALD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "MMXU1", + LnClass = "MMXU", + LnInst = "1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDALD0/MMXU1.A", + Name = "A", + InferredCdc = "WYE", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + Attribute("IEDALD0/MMXU1.A.phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute("IEDALD0/MMXU1.A.phsB.cVal.mag.f", "phsB.cVal.mag.f") + ] + }, + new LiveIedDataObjectModel + { + Reference = "IEDALD0/MMXU1.TotW", + Name = "TotW", + InferredCdc = "MV", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + Attribute("IEDALD0/MMXU1.TotW.mag.f", "mag.f") + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "IEDALD0/LLN0.Analog", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = 2, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = "IEDALD0/MMXU1.A.phsA", + FunctionalConstraint = "MX" + }, + new LiveIedDataSetMemberModel + { + Index = 1, + Reference = "IEDALD0/MMXU1.TotW", + FunctionalConstraint = "MX" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.BR.Buffer01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Buffer01", + Buffered = true, + DataSetReference = "IEDALD0/LLN0.Analog" + }, + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.RP.Unbuffer01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Unbuffer01", + Buffered = false, + DataSetReference = "IEDALD0/LLN0.Analog" + } + ] + }; + + return CanonicalLiveModelAdapter.FromLiveDiscovery(live); + } + + private static LiveIedDataAttributeModel Attribute(string reference, string path) + => new() + { + ObjectReference = reference, + AttributePath = path, + FunctionalConstraint = "MX", + SclBType = "FLOAT32", + MmsType = "floating-point", + MmsTypeSignature = "floating-point", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static CanonicalIedModel WithIngress( + CanonicalIedModel model, + CanonicalIngressKind ingress) + => new() + { + SchemaVersion = model.SchemaVersion, + GeneratedAtUtc = model.GeneratedAtUtc, + Identity = model.Identity, + Communication = model.Communication, + Source = new CanonicalSourceEnvelope + { + Ingress = ingress, + SourceName = ingress == CanonicalIngressKind.SclFile ? "sample.scd" : "LiveMmsDiscovery" + }, + Strings = model.Strings, + AccessPoints = model.AccessPoints, + LogicalDevices = model.LogicalDevices, + LogicalNodes = model.LogicalNodes, + DataObjects = model.DataObjects, + Signals = model.Signals, + DataSets = model.DataSets, + ReportControls = model.ReportControls, + Diagnostics = model.Diagnostics + }; +} From ac73e5edafcf4605e45e180856f90ce2295565b5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:41:01 +0700 Subject: [PATCH 023/176] chore(canonical): keep static coverage resolver allocation-minimal (#144) --- .../Canonical/CanonicalStaticReportCoverageResolver.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs index e1c5952d..c2b21eb1 100644 --- a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs @@ -89,11 +89,6 @@ public static CanonicalStaticReportCoveragePlan Resolve( for (var index = 0; index < requested.Length; index++) results[index] = ResolveSignal(model, requested[index], signalIndex, reportByDataSet); - var coveredReferences = results - .Where(result => result.IsCovered) - .Select(result => result.CanonicalReference) - .ToHashSet(StringComparer.Ordinal); - var segmentDataSets = results .Where(result => result.IsCovered) .SelectMany(result => result.DataSetCandidates) From 9cfc1ae5ff26080b6048c17d7527717dfffd3e8a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:45:34 +0700 Subject: [PATCH 024/176] fix(canonical): converge SCL and live configured reference syntax in coverage (#144) --- .../CanonicalStaticReportCoverageResolver.cs | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs index c2b21eb1..c34a433a 100644 --- a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs @@ -18,6 +18,7 @@ public sealed class CanonicalStaticReportSelection public sealed class CanonicalStaticReportDataSetCandidate { public string DataSetReference { get; init; } = string.Empty; + public string SourceDataSetReference { get; init; } = string.Empty; public int[] MemberIndexes { get; init; } = Array.Empty(); public string[] ReportControlReferences { get; init; } = Array.Empty(); } @@ -37,6 +38,7 @@ public sealed class CanonicalStaticReportSignalCoverage public sealed class CanonicalStaticReportCoverageSegment { public string DataSetReference { get; init; } = string.Empty; + public string SourceDataSetReference { get; init; } = string.Empty; public CanonicalDataSetMember[] OrderedMembers { get; init; } = Array.Empty(); public CanonicalReportControl[] ReportControls { get; init; } = Array.Empty(); public string[] SelectedSignalReferences { get; init; } = Array.Empty(); @@ -79,10 +81,12 @@ public static CanonicalStaticReportCoveragePlan Resolve( var signalIndex = BuildSignalIndex(model); var reportByDataSet = model.ReportControls .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference)) - .GroupBy(report => report.DataSetReference, StringComparer.Ordinal) + .GroupBy(report => NormalizeConfiguredReference(report.DataSetReference), StringComparer.Ordinal) .ToDictionary( group => group.Key, - group => group.OrderBy(report => report.Reference, StringComparer.Ordinal).ToArray(), + group => group + .OrderBy(report => NormalizeConfiguredReference(report.Reference), StringComparer.Ordinal) + .ToArray(), StringComparer.Ordinal); var results = new CanonicalStaticReportSignalCoverage[requested.Length]; @@ -171,12 +175,16 @@ private static CanonicalStaticReportSignalCoverage ResolveSignal( continue; anyDataSet = true; - reportByDataSet.TryGetValue(dataSet.Reference, out var reports); + var canonicalDataSetReference = NormalizeConfiguredReference(dataSet.Reference); + reportByDataSet.TryGetValue(canonicalDataSetReference, out var reports); candidates.Add(new CanonicalStaticReportDataSetCandidate { - DataSetReference = dataSet.Reference, + DataSetReference = canonicalDataSetReference, + SourceDataSetReference = dataSet.Reference, MemberIndexes = memberIndexes, - ReportControlReferences = reports?.Select(report => report.Reference).ToArray() ?? Array.Empty() + ReportControlReferences = reports? + .Select(report => NormalizeConfiguredReference(report.Reference)) + .ToArray() ?? Array.Empty() }); } @@ -209,7 +217,10 @@ private static CanonicalStaticReportSignalCoverage ResolveSignal( IReadOnlyDictionary reportByDataSet) { var dataSet = model.DataSets.SingleOrDefault(candidate => - string.Equals(candidate.Reference, dataSetReference, StringComparison.Ordinal)); + string.Equals( + NormalizeConfiguredReference(candidate.Reference), + dataSetReference, + StringComparison.Ordinal)); if (dataSet is null) return null; @@ -228,6 +239,7 @@ private static CanonicalStaticReportSignalCoverage ResolveSignal( return new CanonicalStaticReportCoverageSegment { DataSetReference = dataSetReference, + SourceDataSetReference = dataSet.Reference, OrderedMembers = dataSet.Members.OrderBy(member => member.Index).ToArray(), ReportControls = reports, SelectedSignalReferences = selected @@ -259,6 +271,13 @@ private static bool MemberCoversSignal( signalReference.StartsWith(memberReference + ".", StringComparison.Ordinal); } + private static string NormalizeConfiguredReference(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? string.Empty : text.Replace(' +, '.'); + } + private static string NormalizeFc(string? value) => (value ?? string.Empty).Trim().ToUpperInvariant(); } From c8addff5903c2ccefb9aed35c9e680b93f94d2df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:46:05 +0700 Subject: [PATCH 025/176] test(canonical): prove Discovery and Open-SCL builder coverage convergence (#144) --- ...onicalStaticReportCoverageResolverTests.cs | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs index 0956e762..b7c32794 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs @@ -1,5 +1,7 @@ +using System.Xml.Linq; using AR.Iec61850.Discovery; using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Scl.Engineering; namespace AR.Iec61850.Tests.Engineering; @@ -34,6 +36,50 @@ public void Discovery_And_Scl_Ingress_Use_The_Same_Static_Coverage_Contract() fromScl.Signals[0].DataSetCandidates[0].ReportControlReferences); } + [Fact] + public void Real_Discovery_And_OpenScl_Builders_Converge_On_Static_Coverage() + { + var discoveryModel = BuildStatusDiscoveryCanonical(); + var imported = SclCanonicalImporter.Import( + XDocument.Parse(BuildEquivalentStatusScl()), + new SclCanonicalImportOptions + { + IedName = "IEDA", + AccessPointName = "P1", + SourceName = "equivalent.cid" + }); + + Assert.True(imported.IsSuccess, string.Join(" | ", imported.Errors)); + var sclModel = Assert.IsType(imported.Model); + + var selection = new[] + { + new CanonicalStaticReportSelection + { + Reference = "IEDALD0/GGIO1.Alm.stVal", + FunctionalConstraint = "ST" + } + }; + + var discoveryCoverage = CanonicalStaticReportCoverageResolver.Resolve(discoveryModel, selection); + var sclCoverage = CanonicalStaticReportCoverageResolver.Resolve(sclModel, selection); + + Assert.Equal(1, discoveryCoverage.CoveredSignalCount); + Assert.Equal(1, sclCoverage.CoveredSignalCount); + Assert.Equal( + discoveryCoverage.Signals[0].DataSetCandidates[0].DataSetReference, + sclCoverage.Signals[0].DataSetCandidates[0].DataSetReference); + Assert.Equal( + "IEDALD0/LLN0.Indications", + sclCoverage.Signals[0].DataSetCandidates[0].DataSetReference); + Assert.Equal( + discoveryCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences, + sclCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences); + Assert.Equal( + ["IEDALD0/LLN0.BR.Rpt01"], + sclCoverage.Signals[0].DataSetCandidates[0].ReportControlReferences); + } + [Fact] public void Structured_Member_Covers_Only_Exact_Boundary_Descendants() { @@ -147,6 +193,127 @@ public void DataSet_Without_Rcb_Is_Not_Promoted_To_Report_Coverage() Assert.Empty(plan.Segments); } + private static CanonicalIedModel BuildStatusDiscoveryCanonical() + { + var live = new LiveIedModelDiscoveryDocument + { + Source = "LiveMmsDiscovery", + IedName = "IEDA", + AccessPointName = "P1", + IedIdentity = new LiveIedIdentity + { + IedName = "IEDA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }, + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDALD0", + Inst = "LD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDALD0/GGIO1.Alm", + Name = "Alm", + InferredCdc = "SPS", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = "IEDALD0/GGIO1.Alm.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = "BOOLEAN", + MmsType = "boolean", + MmsTypeSignature = "boolean", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "IEDALD0/LLN0.Indications", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Indications", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 1, + Reference = "IEDALD0/GGIO1.Alm", + FunctionalConstraint = "ST" + } + ] + } + ], + ReportControls = + [ + new LiveIedReportControlModel + { + Reference = "IEDALD0/LLN0.BR.Rpt01", + Domain = "IEDALD0", + LogicalNode = "LLN0", + Name = "Rpt01", + Buffered = true, + DataSetReference = "IEDALD0/LLN0.Indications" + } + ] + }; + + return CanonicalLiveModelAdapter.FromLiveDiscovery(live); + } + + private static string BuildEquivalentStatusScl() + => """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + private static CanonicalIedModel BuildCanonical() { var live = new LiveIedModelDiscoveryDocument From fab541d81134e5b5fa2e76aada9692c42baf0d06 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:48:55 +0700 Subject: [PATCH 026/176] fix(canonical): repair configured reference normalizer syntax (#144) --- .../Canonical/CanonicalStaticReportCoverageResolver.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs index c34a433a..c1e4ecba 100644 --- a/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalStaticReportCoverageResolver.cs @@ -274,8 +274,7 @@ private static bool MemberCoversSignal( private static string NormalizeConfiguredReference(string? value) { var text = (value ?? string.Empty).Trim(); - return text.Length == 0 ? string.Empty : text.Replace(' -, '.'); + return text.Length == 0 ? string.Empty : text.Replace('$', '.'); } private static string NormalizeFc(string? value) From 8f8b02462101a06b809c0a5ecf60d5f8698be04b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:49:43 +0700 Subject: [PATCH 027/176] feat(reporting): model reduced configured-static RCB eligibility (#144) --- ...MmsConfiguredStaticRcbEligibilityPolicy.cs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs new file mode 100644 index 00000000..563d7de4 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs @@ -0,0 +1,132 @@ +namespace AR.Iec61850.Mms; + +public enum MmsConfiguredStaticRcbEligibilityKind +{ + Blocked, + CallerOwned, + ExactFree, + ReducedMissingReservationEvidence +} + +public sealed class MmsConfiguredStaticRcbEligibility +{ + public MmsConfiguredStaticRcbEligibilityKind Kind { get; init; } + public string Reason { get; init; } = string.Empty; + public bool IsEligible => Kind != MmsConfiguredStaticRcbEligibilityKind.Blocked; + public bool RequiresWrite => Kind is + MmsConfiguredStaticRcbEligibilityKind.ExactFree or + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence; + public bool UsesReducedEvidence => + Kind == MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence; +} + +/// +/// Evaluates only configured static RCB activation eligibility. +/// +/// Exact-free evidence remains preferred. A separately gated reduced state may be emitted +/// only when the configured DataSet binding is freshly read and populated, RptEna is +/// explicitly false, no positive foreign ownership/reservation evidence exists, and the +/// reservation field is genuinely absent/unexposed. Malformed or contradictory values are +/// blockers, never "missing evidence". +/// +/// This policy never relaxes dynamic DataSet mutation eligibility. +/// +public static class MmsConfiguredStaticRcbEligibilityPolicy +{ + public static MmsConfiguredStaticRcbEligibility Evaluate( + MmsRcbAvailabilitySnapshot snapshot, + bool allowCallerOwned, + bool allowReducedMissingReservationEvidence) + { + ArgumentNullException.ThrowIfNull(snapshot); + + if (snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller) + { + var callerReady = allowCallerOwned && + HasVerifiedStaticDataSet(snapshot); + return callerReady + ? Eligible( + MmsConfiguredStaticRcbEligibilityKind.CallerOwned, + "The configured static RCB is already active in this caller association and its populated DataSet is verified.") + : Blocked("Caller-owned RCB reuse is disabled or its configured DataSet is not fully verified."); + } + + if (!HasVerifiedStaticDataSet(snapshot)) + return Blocked("Configured static RCB requires a successful live DatSet read and a populated DataSet directory."); + + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != false) + return Blocked("Configured static RCB requires explicit RptEna=false before a new activation write."); + + if (HasPositiveBusyEvidence(snapshot)) + return Blocked("Positive RCB ownership/reservation evidence indicates the configured static RCB is not free."); + + if (snapshot.Availability == MmsRcbOperationalAvailability.Available && + snapshot.Confidence == MmsRcbAvailabilityConfidence.Exact && + HasExplicitFreeReservation(snapshot)) + { + return Eligible( + MmsConfiguredStaticRcbEligibilityKind.ExactFree, + "Fresh exact evidence confirms a populated configured DataSet, RptEna=false, and explicit free reservation state."); + } + + if (!allowReducedMissingReservationEvidence) + return Blocked("Exact free reservation evidence is unavailable and reduced configured-static activation is not enabled."); + + if (snapshot.Availability != MmsRcbOperationalAvailability.Unknown) + return Blocked($"Reduced configured-static activation accepts only missing reservation evidence; availability={snapshot.Availability} is not an unknown-reservation state."); + + if (!ReservationEvidenceIsGenuinelyMissing(snapshot)) + return Blocked("Reservation evidence is present but is neither explicitly free nor safely classifiable as missing."); + + return Eligible( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + "Configured static DataSet and RptEna=false are verified and no positive busy evidence exists, but the IED does not expose usable reservation metadata. Activation may proceed only under the explicit reduced-evidence policy and must still prove success by readback/report traffic."); + } + + private static bool HasVerifiedStaticDataSet(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded && + snapshot.DataSetDirectorySuccess && + snapshot.DataSetMembers.Count > 0 && + !string.IsNullOrWhiteSpace(snapshot.DataSetReference); + + private static bool HasPositiveBusyEvidence(MmsRcbAvailabilitySnapshot snapshot) + { + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) == true) + return true; + if (MmsRcbAvailabilityEvaluator.HasOwner(snapshot.Owner)) + return true; + + return snapshot.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0 + : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == true; + } + + private static bool HasExplicitFreeReservation(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) == 0 + : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == false; + + private static bool ReservationEvidenceIsGenuinelyMissing(MmsRcbAvailabilitySnapshot snapshot) + => snapshot.Buffered + ? IsMissingToken(snapshot.ReservationTimeSeconds) + : IsMissingToken(snapshot.ReservationState); + + private static bool IsMissingToken(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 || + text == "-" || + text == "[]" || + text.Equals("null", StringComparison.OrdinalIgnoreCase) || + text.Equals("unsupported", StringComparison.OrdinalIgnoreCase) || + text.Equals("not-exposed", StringComparison.OrdinalIgnoreCase); + } + + private static MmsConfiguredStaticRcbEligibility Eligible( + MmsConfiguredStaticRcbEligibilityKind kind, + string reason) + => new() { Kind = kind, Reason = reason }; + + private static MmsConfiguredStaticRcbEligibility Blocked(string reason) + => new() { Kind = MmsConfiguredStaticRcbEligibilityKind.Blocked, Reason = reason }; +} From c04c07b72b48ba332b3c6d383784df467ea5f7df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:50:18 +0700 Subject: [PATCH 028/176] feat(reporting): gate reduced configured-static eligibility explicitly (#144) --- .../Mms/MmsHybridReportAcquisitionPlanner.cs | 69 ++++++++++++++----- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs index 12bb99f8..6ee1257c 100644 --- a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs +++ b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs @@ -47,6 +47,13 @@ public sealed class MmsHybridReportAcquisitionOptions public bool AllowCallerOwnedReports { get; init; } = true; public bool AllowStaticBrcb { get; init; } = true; public bool AllowStaticUrcb { get; init; } = true; + /// + /// Explicit interoperability opt-in for configured static RCBs whose live DataSet + /// and RptEna=false are verified but whose reservation attribute is genuinely + /// unexposed. Default false preserves the exact-evidence production contract. + /// This option never applies to dynamic DataSet mutation. + /// + public bool AllowConfiguredStaticWithMissingReservationEvidence { get; init; } public bool AllowDynamicBrcb { get; init; } = true; public bool AllowDynamicUrcb { get; init; } = true; public bool AllowPollingFallback { get; init; } = true; @@ -347,11 +354,12 @@ private static void PlanStaticCoverage( .Select(snapshot => new { Snapshot = snapshot, + Eligibility = EvaluateStaticEligibility(snapshot, options), Covered = remaining.Where(signal => StaticDataSetCovers(snapshot, signal)).ToArray() }) .Where(item => item.Covered.Length > 0) .OrderByDescending(item => item.Covered.Length) - .ThenBy(item => item.Snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller ? 0 : 1) + .ThenBy(item => StaticEligibilityRank(item.Eligibility.Kind)) .ThenBy(item => item.Snapshot.Buffered ? 0 : 1) .ThenBy(item => item.Snapshot.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -361,7 +369,8 @@ private static void PlanStaticCoverage( var selected = scored[0]; var snapshot = selected.Snapshot; - var plan = snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller + var eligibility = selected.Eligibility; + var plan = eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned ? BuildCallerOwnedStaticPlan(snapshot, inventory) : BuildFreshStaticPlan(snapshot); @@ -373,7 +382,14 @@ private static void PlanStaticCoverage( } var kind = snapshot.Buffered ? MmsHybridAcquisitionKind.StaticBrcb : MmsHybridAcquisitionKind.StaticUrcb; - var callerOwned = snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller; + var callerOwned = eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned; + var reducedEvidence = eligibility.UsesReducedEvidence; + if (reducedEvidence) + { + warnings.Add( + $"Configured static candidate {snapshot.Reference} is using the explicit reduced reservation-evidence policy: {eligibility.Reason}"); + } + var segment = new MmsHybridAcquisitionSegment { Kind = kind, @@ -387,7 +403,9 @@ private static void PlanStaticCoverage( IsAlreadyActiveByCaller = callerOwned, Reason = callerOwned ? "Fresh availability evidence identifies this report as already active in the caller's current session; reuse it without reconfiguration." - : "Fresh exact availability evidence confirms a populated static DataSet and a free RCB; enable the existing report without changing its DataSet membership." + : reducedEvidence + ? eligibility.Reason + : "Fresh availability evidence confirms a populated static DataSet, RptEna=false, and explicit free reservation state; enable the existing report without changing its DataSet membership." }; segments.Add(segment); @@ -681,23 +699,38 @@ private static bool IsStaticUsable(MmsRcbAvailabilitySnapshot snapshot, MmsHybri return false; if (!snapshot.Buffered && !options.AllowStaticUrcb) return false; - if (snapshot.Availability == MmsRcbOperationalAvailability.UsedByCaller) - return options.AllowCallerOwnedReports && - snapshot.DataSetDirectorySuccess && - snapshot.DataSetMembers.Count > 0 && - !string.IsNullOrWhiteSpace(snapshot.DataSetReference); - if (snapshot.Availability != MmsRcbOperationalAvailability.Available) - return false; - if (options.RequireExactAvailabilityEvidence && snapshot.Confidence != MmsRcbAvailabilityConfidence.Exact) + + var eligibility = EvaluateStaticEligibility(snapshot, options); + if (!eligibility.IsEligible) return false; - return snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded && - snapshot.DataSetDirectorySuccess && - snapshot.DataSetMembers.Count > 0 && - !string.IsNullOrWhiteSpace(snapshot.DataSetReference) && - ParseBool(snapshot.EnabledState) == false && - HasExplicitFreeReservation(snapshot); + + if (eligibility.Kind == MmsConfiguredStaticRcbEligibilityKind.CallerOwned) + return true; + + if (eligibility.UsesReducedEvidence) + return options.AllowConfiguredStaticWithMissingReservationEvidence; + + return !options.RequireExactAvailabilityEvidence || + snapshot.Confidence == MmsRcbAvailabilityConfidence.Exact; } + private static MmsConfiguredStaticRcbEligibility EvaluateStaticEligibility( + MmsRcbAvailabilitySnapshot snapshot, + MmsHybridReportAcquisitionOptions options) + => MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + options.AllowCallerOwnedReports, + options.AllowConfiguredStaticWithMissingReservationEvidence); + + private static int StaticEligibilityRank(MmsConfiguredStaticRcbEligibilityKind kind) + => kind switch + { + MmsConfiguredStaticRcbEligibilityKind.CallerOwned => 0, + MmsConfiguredStaticRcbEligibilityKind.ExactFree => 1, + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence => 2, + _ => 9 + }; + private static bool IsDynamicUsable(MmsRcbAvailabilitySnapshot snapshot, MmsHybridReportAcquisitionOptions options) { if (snapshot.Buffered && !options.AllowDynamicBrcb) From beda72386ce861fb1818b7f7e8dc56caf5cab910 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:50:36 +0700 Subject: [PATCH 029/176] fix(reporting): preserve existing reduced-confidence explicit-free semantics (#144) --- .../Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs index 563d7de4..d83140ba 100644 --- a/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs +++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticRcbEligibilityPolicy.cs @@ -4,7 +4,7 @@ public enum MmsConfiguredStaticRcbEligibilityKind { Blocked, CallerOwned, - ExactFree, + ExplicitFree, ReducedMissingReservationEvidence } @@ -14,7 +14,7 @@ public sealed class MmsConfiguredStaticRcbEligibility public string Reason { get; init; } = string.Empty; public bool IsEligible => Kind != MmsConfiguredStaticRcbEligibilityKind.Blocked; public bool RequiresWrite => Kind is - MmsConfiguredStaticRcbEligibilityKind.ExactFree or + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree or MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence; public bool UsesReducedEvidence => Kind == MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence; @@ -61,12 +61,11 @@ public static MmsConfiguredStaticRcbEligibility Evaluate( return Blocked("Positive RCB ownership/reservation evidence indicates the configured static RCB is not free."); if (snapshot.Availability == MmsRcbOperationalAvailability.Available && - snapshot.Confidence == MmsRcbAvailabilityConfidence.Exact && HasExplicitFreeReservation(snapshot)) { return Eligible( - MmsConfiguredStaticRcbEligibilityKind.ExactFree, - "Fresh exact evidence confirms a populated configured DataSet, RptEna=false, and explicit free reservation state."); + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, + $"Fresh evidence confirms a populated configured DataSet, RptEna=false, and explicit free reservation state (confidence={snapshot.Confidence})."); } if (!allowReducedMissingReservationEvidence) From ba392fe73fac6767b440a0b999c130839c76942d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:50:40 +0700 Subject: [PATCH 030/176] fix(reporting): rank explicit-free static eligibility without changing legacy option behavior (#144) --- src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs index 6ee1257c..a39baf6c 100644 --- a/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs +++ b/src/AR.Iec61850/Mms/MmsHybridReportAcquisitionPlanner.cs @@ -726,7 +726,7 @@ private static int StaticEligibilityRank(MmsConfiguredStaticRcbEligibilityKind k => kind switch { MmsConfiguredStaticRcbEligibilityKind.CallerOwned => 0, - MmsConfiguredStaticRcbEligibilityKind.ExactFree => 1, + MmsConfiguredStaticRcbEligibilityKind.ExplicitFree => 1, MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence => 2, _ => 9 }; From 6bdb6b79c2eb350a59e57de0d0b1a6efd53d0c1e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:51:16 +0700 Subject: [PATCH 031/176] test(reporting): lock missing-vs-blocking static RCB evidence policy (#144) --- ...nfiguredStaticRcbEligibilityPolicyTests.cs | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs new file mode 100644 index 00000000..5031f57d --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticRcbEligibilityPolicyTests.cs @@ -0,0 +1,185 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsConfiguredStaticRcbEligibilityPolicyTests +{ + [Fact] + public void Exact_Explicit_Free_Brcb_Remains_Preferred() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Available, + confidence: MmsRcbAvailabilityConfidence.Exact, + reservationTime: "0"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: false); + + Assert.True(result.IsEligible); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, result.Kind); + Assert.False(result.UsesReducedEvidence); + } + + [Fact] + public void Missing_Brcb_Reservation_Evidence_Is_Blocked_By_Default() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: string.Empty); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: false); + + Assert.False(result.IsEligible); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.Blocked, result.Kind); + } + + [Fact] + public void Explicit_OptIn_Allows_Only_Genuinely_Missing_Brcb_Reservation_Evidence() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: "-"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.True(result.IsEligible); + Assert.True(result.RequiresWrite); + Assert.True(result.UsesReducedEvidence); + Assert.Equal( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + result.Kind); + } + + [Fact] + public void Malformed_Reservation_Evidence_Is_Not_Downgraded_To_Missing() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: "vendor-weird"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("neither explicitly free", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Positive_Owner_Evidence_Always_Blocks_Reduced_Path() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationTime: string.Empty, + owner: "01020304"); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("ownership/reservation", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Missing_Urcb_Resv_Can_Use_The_Same_Explicit_Reduced_Policy() + { + var snapshot = StaticSnapshot( + buffered: false, + availability: MmsRcbOperationalAvailability.Unknown, + confidence: MmsRcbAvailabilityConfidence.Reduced, + reservationState: string.Empty); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.True(result.IsEligible); + Assert.Equal( + MmsConfiguredStaticRcbEligibilityKind.ReducedMissingReservationEvidence, + result.Kind); + } + + [Fact] + public void Dynamic_Empty_Rcb_Is_Never_Promoted_By_Configured_Static_Policy() + { + var snapshot = StaticSnapshot( + buffered: true, + availability: MmsRcbOperationalAvailability.NoDataSet, + confidence: MmsRcbAvailabilityConfidence.Exact, + reservationTime: string.Empty, + dataSetReference: string.Empty, + members: Array.Empty()); + + var result = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, + allowCallerOwned: true, + allowReducedMissingReservationEvidence: true); + + Assert.False(result.IsEligible); + Assert.Contains("configured static", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + private static MmsRcbAvailabilitySnapshot StaticSnapshot( + bool buffered, + MmsRcbOperationalAvailability availability, + MmsRcbAvailabilityConfidence confidence, + string reservationTime = "", + string reservationState = "", + string owner = "", + string dataSetReference = "LD0/LLN0.dsA", + IReadOnlyList? members = null) + { + members ??= + [ + new MmsDataSetDirectoryMember + { + Domain = "LD0", + MmsItemName = "GGIO1$ST$Ind1", + UserReference = "LD0/GGIO1.Ind1", + FunctionalConstraint = "ST" + } + ]; + + return new MmsRcbAvailabilitySnapshot + { + Reference = buffered ? "LD0/LLN0.BR.B01" : "LD0/LLN0.RP.U01", + Domain = "LD0", + LogicalNode = "LLN0", + Name = buffered ? "B01" : "U01", + Buffered = buffered, + DataSetReference = dataSetReference, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = "false", + ReservationTimeSeconds = reservationTime, + ReservationState = reservationState, + Owner = owner, + DataSetDirectoryRead = true, + DataSetDirectorySuccess = members.Count > 0, + DataSetMemberCount = members.Count, + DataSetMembers = members, + Availability = availability, + Confidence = confidence + }; + } +} From e1d4d3434621245c8d597351433e1d212e00db49 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:51:37 +0700 Subject: [PATCH 032/176] test(reporting): prove reduced static policy is explicit and static-first (#144) --- ...sHybridReportAcquisitionPlannerP22Tests.cs | 81 ++++++++++++++++++- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs index a8c9ee3a..0634f2f4 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionPlannerP22Tests.cs @@ -213,6 +213,76 @@ public void PollingDisabled_LeavesResidualUncovered_AndNeverCallsItMissing() Assert.All(plan.Assignments, x => Assert.Contains("No absence conclusion", x.Reason, StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void ConfiguredStatic_WithMissingReservationEvidence_RemainsPollingByDefault() + { + var signal = Signal( + "LD0/GGIO1.Ind1.stVal", + "LD0/GGIO1$ST$Ind1$stVal", + "ST", + "LD0/LLN0.dsA", + "LD0/GGIO1.Ind1"); + var inventory = Inventory(Rcb("LD0/LLN0.BR.B01", true, "LD0/LLN0.dsA")); + var unknown = Copy( + StaticAvailable( + "LD0/LLN0.BR.B01", + true, + "LD0/LLN0.dsA", + Member("LD0", "GGIO1$ST$Ind1", "LD0/GGIO1.Ind1", "ST")), + availability: MmsRcbOperationalAvailability.Unknown, + reservationTimeSeconds: string.Empty, + confidence: MmsRcbAvailabilityConfidence.Reduced); + + var plan = Build([signal], inventory, Availability(unknown), EmptyDirectory()); + + Assert.Equal(MmsHybridAcquisitionPlanStatus.PollingOnly, plan.Status); + Assert.Equal(0, plan.StaticBrcbSignalCount); + Assert.Equal(1, plan.PollingFallbackSignalCount); + Assert.Equal(0, plan.Capability.StaticUsableCount); + } + + [Fact] + public void Explicit_Reduced_Static_OptIn_Uses_ConfiguredBrcb_BeforePolling() + { + var signal = Signal( + "LD0/GGIO1.Ind1.stVal", + "LD0/GGIO1$ST$Ind1$stVal", + "ST", + "LD0/LLN0.dsA", + "LD0/GGIO1.Ind1"); + var inventory = Inventory(Rcb("LD0/LLN0.BR.B01", true, "LD0/LLN0.dsA")); + var unknown = Copy( + StaticAvailable( + "LD0/LLN0.BR.B01", + true, + "LD0/LLN0.dsA", + Member("LD0", "GGIO1$ST$Ind1", "LD0/GGIO1.Ind1", "ST")), + availability: MmsRcbOperationalAvailability.Unknown, + reservationTimeSeconds: string.Empty, + confidence: MmsRcbAvailabilityConfidence.Reduced); + + var plan = Build( + [signal], + inventory, + Availability(unknown), + EmptyDirectory(), + new MmsHybridReportAcquisitionOptions + { + AllowConfiguredStaticWithMissingReservationEvidence = true + }); + + Assert.Equal(MmsHybridAcquisitionPlanStatus.FullReportCoverage, plan.Status); + Assert.Equal(1, plan.StaticBrcbSignalCount); + Assert.Equal(0, plan.PollingFallbackSignalCount); + Assert.Equal(1, plan.Capability.StaticUsableCount); + var segment = Assert.Single(plan.Segments); + Assert.Equal(MmsHybridAcquisitionKind.StaticBrcb, segment.Kind); + Assert.True(segment.RequiresWrite); + Assert.Contains("reduced", segment.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains(plan.Warnings, warning => + warning.Contains("reduced reservation-evidence", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Capability_DistinguishesDiscoveredCheckedUsableBusyAndUnknownRcbs() { @@ -389,7 +459,10 @@ private static MmsRcbAvailabilitySnapshot Copy( MmsRcbAvailabilitySnapshot source, MmsRcbOperationalAvailability? availability = null, string? enabledState = null, - string? reservationState = null) + string? reservationState = null, + string? reservationTimeSeconds = null, + string? owner = null, + MmsRcbAvailabilityConfidence? confidence = null) => new() { CheckedAtUtc = source.CheckedAtUtc, @@ -410,15 +483,15 @@ private static MmsRcbAvailabilitySnapshot Copy( OptionalFields = source.OptionalFields, EnabledState = enabledState ?? source.EnabledState, ReservationState = reservationState ?? source.ReservationState, - ReservationTimeSeconds = source.ReservationTimeSeconds, - Owner = source.Owner, + ReservationTimeSeconds = reservationTimeSeconds ?? source.ReservationTimeSeconds, + Owner = owner ?? source.Owner, DataSetDirectoryRead = source.DataSetDirectoryRead, DataSetDirectorySuccess = source.DataSetDirectorySuccess, DataSetIsDeletable = source.DataSetIsDeletable, DataSetMemberCount = source.DataSetMemberCount, DataSetMembers = source.DataSetMembers, Availability = availability ?? source.Availability, - Confidence = source.Confidence, + Confidence = confidence ?? source.Confidence, Reason = source.Reason, ProbeDiagnostics = source.ProbeDiagnostics }; From 70e6e9edde47ccc93eed4b755d100c36346baada Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:52:03 +0700 Subject: [PATCH 033/176] test(reporting): keep reduced static interoperability disabled by default (#144) --- .../Mms/MmsHybridReportAcquisitionContractP22Tests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs index 1d112a7c..b709fbd8 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsHybridReportAcquisitionContractP22Tests.cs @@ -17,6 +17,7 @@ public void DefaultOptions_AreBoundedAndFailClosed() Assert.True(options.AllowCallerOwnedReports); Assert.True(options.AllowStaticBrcb); Assert.True(options.AllowStaticUrcb); + Assert.False(options.AllowConfiguredStaticWithMissingReservationEvidence); Assert.True(options.AllowDynamicBrcb); Assert.True(options.AllowDynamicUrcb); Assert.True(options.AllowPollingFallback); From 4cc9b49e49d1b5074b7115905e521204a45b4940 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:55:18 +0700 Subject: [PATCH 034/176] fix(reporting): route fresh static hybrid plans through configured lifecycle (#144) --- ...sPersistentReportMonitorAttemptEvidence.cs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs index 95472db6..9e039625 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs @@ -98,12 +98,22 @@ public async Task StartPersistentReport } } - var start = await StartPersistentReportMonitorAsync( - plan, - triggerGeneralInterrogation, - deleteDynamicDataSetOnStop, - directory, - cancellationToken).ConfigureAwait(false); + // A fresh configured static plan must use the same source-neutral activation + // lifecycle regardless of whether its model originated from live Discovery or SCL: + // receiver registration -> direct RptEna -> bounded BRCB ResvTms fallback -> + // readback -> optional GI. Dynamic plans retain the dynamic transaction path below. + var start = !isDynamic && + plan.Status == MmsReportSubscriptionPlanStatus.ReadyRequiresWrite + ? await StartConfiguredStaticReportMonitorAsync( + plan, + triggerGeneralInterrogation, + cancellationToken).ConfigureAwait(false) + : await StartPersistentReportMonitorAsync( + plan, + triggerGeneralInterrogation, + deleteDynamicDataSetOnStop, + directory, + cancellationToken).ConfigureAwait(false); if (probe is not null) start = MergeProbeEvidence(start, probe); From 4a915bf172f056d0d1190f0e6b271cfc8130f7e5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:59:23 +0700 Subject: [PATCH 035/176] feat(diagnostics): surface typed RCB mutation failure evidence (#144) --- .../Mms/MmsReportSessionDiagnostics.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs b/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs index 8ad5286d..c100a44b 100644 --- a/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs +++ b/src/AR.Iec61850/Mms/MmsReportSessionDiagnostics.cs @@ -11,6 +11,11 @@ public sealed class MmsReportSessionDiagnostics public int ValueCount { get; init; } public int WriteStepCount { get; init; } public int WriteFailureCount { get; init; } + public int WritePreflightBlockedCount { get; init; } + public int WriteTypeMismatchCount { get; init; } + public int WriteAccessDeniedCount { get; init; } + public int WriteTypeEvidenceConflictCount { get; init; } + public int WriteTypeEvidenceUnavailableCount { get; init; } public int PollReadCount { get; init; } public int PollReadSuccessCount { get; init; } public int PollReadFailureCount { get; init; } @@ -67,7 +72,8 @@ public IReadOnlyList WarningMessages public string Summary => $"diagnostics={OverallStatus}, reports={ReportCount}, values={ValueCount}, mappedFailures={MappingFailureCount}, partialMappings={PartialMappingFailureCount}, " + - $"pollReads={PollReadSuccessCount}/{PollReadCount}, writeFailures={WriteFailureCount}, " + + $"pollReads={PollReadSuccessCount}/{PollReadCount}, writeFailures={WriteFailureCount}, preflightBlocked={WritePreflightBlockedCount}, " + + $"typeMismatch={WriteTypeMismatchCount}, typeEvidenceConflict={WriteTypeEvidenceConflictCount}, " + $"seqGaps={SequenceGapCount}, seqResets={SequenceResetCount}, seqRegressions={SequenceRegressionCount}, " + $"entryIdGaps={EntryIdGapCount}, entryIdRegressions={EntryIdRegressionCount}, " + $"duplicates={DuplicateReportKeyCount}, bufOvfl={BufferOverflowObserved.ToString().ToLowerInvariant()}"; @@ -177,6 +183,13 @@ public static MmsReportSessionDiagnostics Analyze( ValueCount = reports.Sum(x => x.Values.Count), WriteStepCount = writeSteps.Count, WriteFailureCount = writeSteps.Count(x => !x.IsSuccess), + WritePreflightBlockedCount = writeSteps.Count(x => !x.Attempted && !x.IsSuccess), + WriteTypeMismatchCount = writeSteps.Count(x => x.FailureKind == MmsInteropFailureKind.TypeMismatch), + WriteAccessDeniedCount = writeSteps.Count(x => x.FailureKind == MmsInteropFailureKind.AccessDenied), + WriteTypeEvidenceConflictCount = writeSteps.Count(x => + x.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.ExactMismatch), + WriteTypeEvidenceUnavailableCount = writeSteps.Count(x => + x.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.Unavailable), PollReadCount = pollReads.Count, PollReadSuccessCount = pollReads.Count(x => x.IsSuccess), PollReadFailureCount = pollReads.Count(x => !x.IsSuccess), From 45e88553630b7723802b4c311a0ea57ee51b105f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:59:40 +0700 Subject: [PATCH 036/176] test(diagnostics): lock typed RCB interoperability counters (#144) --- .../Mms/MmsReportSessionDiagnosticsTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs index e1e84ef1..eabc10d6 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportSessionDiagnosticsTests.cs @@ -108,4 +108,70 @@ public void Diagnostics_treats_sequence_zero_after_nonzero_as_reset_warning_not_ Assert.Contains(diagnostics.WarningMessages, x => x.Contains("reset-to-zero", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Diagnostics_surfaces_structured_RCB_type_and_preflight_failures() + { + var writes = new[] + { + new MmsReportAttributeWriteStep + { + Attribute = "ResvTms", + Reference = "LD0/LLN0.BR.B01.ResvTms", + Attempted = true, + IsSuccess = false, + FailureCode = 7, + FailureName = "type-inconsistent", + FailureKind = MmsInteropFailureKind.TypeMismatch, + RecoveryHint = MmsInteropRecoveryHint.RevalidateType, + TypeEvidence = new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.Unavailable, + Attribute = "ResvTms", + ExpectedMmsType = "integer", + Message = "Live TypeSpecification unavailable." + } + }, + new MmsReportAttributeWriteStep + { + Attribute = "RptEna", + Reference = "LD0/LLN0.BR.B01.RptEna", + Attempted = false, + IsSuccess = false, + TypeEvidence = new MmsReportSemanticTypeEvidence + { + Status = MmsReportSemanticTypeEvidenceStatus.ExactMismatch, + Attribute = "RptEna", + ExpectedMmsType = "boolean", + LiveMmsType = "integer" + } + }, + new MmsReportAttributeWriteStep + { + Attribute = "GI", + Reference = "LD0/LLN0.BR.B01.GI", + Attempted = true, + IsSuccess = false, + FailureCode = 3, + FailureName = "object-access-denied", + FailureKind = MmsInteropFailureKind.AccessDenied, + RecoveryHint = MmsInteropRecoveryHint.FailClosed + } + }; + + var diagnostics = MmsReportSessionDiagnostics.Analyze( + Array.Empty(), + writeSteps: writes); + + Assert.Equal("FAIL", diagnostics.OverallStatus); + Assert.Equal(3, diagnostics.WriteFailureCount); + Assert.Equal(1, diagnostics.WritePreflightBlockedCount); + Assert.Equal(1, diagnostics.WriteTypeMismatchCount); + Assert.Equal(1, diagnostics.WriteAccessDeniedCount); + Assert.Equal(1, diagnostics.WriteTypeEvidenceConflictCount); + Assert.Equal(1, diagnostics.WriteTypeEvidenceUnavailableCount); + Assert.Contains("typeMismatch=1", diagnostics.Summary, StringComparison.Ordinal); + Assert.Contains("preflightBlocked=1", diagnostics.Summary, StringComparison.Ordinal); + } + + } From 815d2263e0369d381f9135d8358046d8ee74dfe9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:00:01 +0700 Subject: [PATCH 037/176] feat(reporting): classify static activation failures from typed evidence (#144) --- .../MmsPersistentReportMonitorAttemptEvidence.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs index 9e039625..a2942421 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorAttemptEvidence.cs @@ -19,7 +19,10 @@ public enum MmsReportActivationFailureReason DynamicDataSetBindFailed, TriggerOptionsUnavailable, TriggerOptionsWriteFailed, + RcbFieldTypeConflict, + ReservationWriteFailed, ReportEnableFailed, + GeneralInterrogationFailed, ActivationException, OtherActivationFailure } @@ -274,9 +277,15 @@ private static bool SuccessfulStep(IEnumerable step private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentReportMonitorStartResult result) { - var failed = result.WriteSteps.LastOrDefault(step => step.Attempted && !step.IsSuccess); + var failed = result.WriteSteps.LastOrDefault(step => !step.IsSuccess); if (failed is not null) { + if (failed.TypeEvidence?.Status == MmsReportSemanticTypeEvidenceStatus.ExactMismatch || + failed.FailureKind is MmsInteropFailureKind.TypeMismatch or MmsInteropFailureKind.TypeUnsupported) + { + return MmsReportActivationFailureReason.RcbFieldTypeConflict; + } + if (failed.Attribute.Equals("Probe.DefineNamedVariableList", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.DynamicDataSetProbeDefineFailed; if (failed.Attribute.Equals("Probe.GetNamedVariableListAttributes", StringComparison.OrdinalIgnoreCase)) @@ -289,8 +298,13 @@ private static MmsReportActivationFailureReason ClassifyFailure(MmsPersistentRep return MmsReportActivationFailureReason.DynamicDataSetBindFailed; if (failed.Attribute.Equals("TrgOps", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.TriggerOptionsWriteFailed; + if (failed.Attribute.Equals("Resv", StringComparison.OrdinalIgnoreCase) || + failed.Attribute.Equals("ResvTms", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.ReservationWriteFailed; if (failed.Attribute.Equals("RptEna", StringComparison.OrdinalIgnoreCase)) return MmsReportActivationFailureReason.ReportEnableFailed; + if (failed.Attribute.Equals("GI", StringComparison.OrdinalIgnoreCase)) + return MmsReportActivationFailureReason.GeneralInterrogationFailed; } if (result.Message.Contains("requires a writable TrgOps", StringComparison.OrdinalIgnoreCase)) From 706ad5354fbfd99c4306685f324c329f1c75404a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:01:07 +0700 Subject: [PATCH 038/176] refactor(canonical): normalize configured DataSet and RCB identity at ingress (#144) --- .../Canonical/CanonicalLiveModelAdapter.cs | 109 +++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs index bd60b925..f41e5c74 100644 --- a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs @@ -150,7 +150,7 @@ private static CanonicalIedModel Build( dataSets[i] = new CanonicalDataSet { - Reference = RemapReference(dataSet.Reference, context.DomainAliases), + Reference = NormalizeConfiguredReference(dataSet.Reference, context.DomainAliases), MmsDomain = RemapDomain(dataSet.Domain, context.DomainAliases), LogicalNode = dataSet.LogicalNode, Name = dataSet.Name, @@ -167,12 +167,12 @@ private static CanonicalIedModel Build( var report = source.ReportControls[i]; reportControls[i] = new CanonicalReportControl { - Reference = RemapReference(report.Reference, context.DomainAliases), + Reference = NormalizeConfiguredReference(report.Reference, context.DomainAliases), MmsDomain = RemapDomain(report.Domain, context.DomainAliases), LogicalNode = report.LogicalNode, Name = report.Name, Buffered = report.Buffered, - DataSetReference = RemapReference(report.DataSetReference, context.DomainAliases), + DataSetReference = NormalizeConfiguredReference(report.DataSetReference, context.DomainAliases), ReportId = report.ReportId, ConfRev = report.ConfRev, TriggerOptions = report.TriggerOptions, @@ -232,6 +232,109 @@ private static CanonicalIedModel Build( }; } + /// + /// Canonical DataSet/ReportControl identity uses one source-neutral dotted form. + /// SCL commonly carries ' { + var normalized = value?.Trim() ?? string.Empty; + return aliases.TryGetValue(normalized, out var exact) ? exact : normalized; + } + + private static string RemapReference(string value, IReadOnlyDictionary aliases) + { + var normalized = value?.Trim() ?? string.Empty; + if (normalized.Length == 0 || aliases.Count == 0) + return normalized; + + var slash = normalized.IndexOf('/'); + var domain = slash >= 0 ? normalized[..slash] : normalized; + if (!aliases.TryGetValue(domain, out var exact)) + return normalized; + + return slash >= 0 ? exact + normalized[slash..] : exact; + } + + private static CanonicalConfidence MapConfidence(LiveIedDiscoveryConfidenceLevel confidence) + => confidence switch + { + LiveIedDiscoveryConfidenceLevel.Exact => CanonicalConfidence.Exact, + LiveIedDiscoveryConfidenceLevel.High => CanonicalConfidence.High, + LiveIedDiscoveryConfidenceLevel.Medium => CanonicalConfidence.Medium, + LiveIedDiscoveryConfidenceLevel.Low => CanonicalConfidence.Low, + _ => CanonicalConfidence.Unknown + }; + + private sealed class CanonicalIngressContext + { + public CanonicalIngressKind Ingress { get; init; } + public string SourceName { get; init; } = string.Empty; + public string SourceEdition { get; init; } = string.Empty; + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public CanonicalEvidenceSource IdentitySource { get; init; } + public CanonicalConfidence IdentityConfidence { get; init; } + public bool IdentityAmbiguous { get; init; } + public string[] IdentityCandidates { get; init; } = Array.Empty(); + public string[] IdentityEvidence { get; init; } = Array.Empty(); + public CanonicalEvidenceSource FactSource { get; init; } + public IReadOnlyDictionary DomainAliases { get; init; } = EmptyAliases; + public string[] OriginalTypeAliases { get; init; } = Array.Empty(); + } +} + MMS component separators while live discovery may expose + /// the same configured resource with dots. Source projections retain their original + /// spelling; the shared CanonicalIedModel does not. + /// + private static string NormalizeConfiguredReference( + string value, + IReadOnlyDictionary aliases) + => RemapReference(value, aliases).Replace(' { + var normalized = value?.Trim() ?? string.Empty; + return aliases.TryGetValue(normalized, out var exact) ? exact : normalized; + } + + private static string RemapReference(string value, IReadOnlyDictionary aliases) + { + var normalized = value?.Trim() ?? string.Empty; + if (normalized.Length == 0 || aliases.Count == 0) + return normalized; + + var slash = normalized.IndexOf('/'); + var domain = slash >= 0 ? normalized[..slash] : normalized; + if (!aliases.TryGetValue(domain, out var exact)) + return normalized; + + return slash >= 0 ? exact + normalized[slash..] : exact; + } + + private static CanonicalConfidence MapConfidence(LiveIedDiscoveryConfidenceLevel confidence) + => confidence switch + { + LiveIedDiscoveryConfidenceLevel.Exact => CanonicalConfidence.Exact, + LiveIedDiscoveryConfidenceLevel.High => CanonicalConfidence.High, + LiveIedDiscoveryConfidenceLevel.Medium => CanonicalConfidence.Medium, + LiveIedDiscoveryConfidenceLevel.Low => CanonicalConfidence.Low, + _ => CanonicalConfidence.Unknown + }; + + private sealed class CanonicalIngressContext + { + public CanonicalIngressKind Ingress { get; init; } + public string SourceName { get; init; } = string.Empty; + public string SourceEdition { get; init; } = string.Empty; + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public CanonicalEvidenceSource IdentitySource { get; init; } + public CanonicalConfidence IdentityConfidence { get; init; } + public bool IdentityAmbiguous { get; init; } + public string[] IdentityCandidates { get; init; } = Array.Empty(); + public string[] IdentityEvidence { get; init; } = Array.Empty(); + public CanonicalEvidenceSource FactSource { get; init; } + public IReadOnlyDictionary DomainAliases { get; init; } = EmptyAliases; + public string[] OriginalTypeAliases { get; init; } = Array.Empty(); + } +} +, '.'); + private static string RemapDomain(string value, IReadOnlyDictionary aliases) { var normalized = value?.Trim() ?? string.Empty; From 18d0ac1ce28d0e5bda81ebd9e03478da0ce048de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:01:28 +0700 Subject: [PATCH 039/176] test(canonical): require configured identity equality across Discovery and SCL ingress (#144) --- ...CanonicalStaticReportCoverageResolverTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs index b7c32794..6d3a7170 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalStaticReportCoverageResolverTests.cs @@ -52,6 +52,22 @@ public void Real_Discovery_And_OpenScl_Builders_Converge_On_Static_Coverage() Assert.True(imported.IsSuccess, string.Join(" | ", imported.Errors)); var sclModel = Assert.IsType(imported.Model); + Assert.Equal( + discoveryModel.DataSets.Single().Reference, + sclModel.DataSets.Single().Reference); + Assert.Equal( + "IEDALD0/LLN0.Indications", + sclModel.DataSets.Single().Reference); + Assert.Equal( + discoveryModel.ReportControls.Single().Reference, + sclModel.ReportControls.Single().Reference); + Assert.Equal( + "IEDALD0/LLN0.BR.Rpt01", + sclModel.ReportControls.Single().Reference); + Assert.Equal( + discoveryModel.ReportControls.Single().DataSetReference, + sclModel.ReportControls.Single().DataSetReference); + var selection = new[] { new CanonicalStaticReportSelection From 302f7882cb3a52c1ec9604026717fc42496041b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:11:29 +0700 Subject: [PATCH 040/176] fix(canonical): repair configured-reference ingress normalizer after parallel edit (#144) --- .../Canonical/CanonicalLiveModelAdapter.cs | 96 +------------------ 1 file changed, 2 insertions(+), 94 deletions(-) diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs index f41e5c74..0d9cb2db 100644 --- a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs @@ -234,106 +234,14 @@ private static CanonicalIedModel Build( /// /// Canonical DataSet/ReportControl identity uses one source-neutral dotted form. - /// SCL commonly carries ' { - var normalized = value?.Trim() ?? string.Empty; - return aliases.TryGetValue(normalized, out var exact) ? exact : normalized; - } - - private static string RemapReference(string value, IReadOnlyDictionary aliases) - { - var normalized = value?.Trim() ?? string.Empty; - if (normalized.Length == 0 || aliases.Count == 0) - return normalized; - - var slash = normalized.IndexOf('/'); - var domain = slash >= 0 ? normalized[..slash] : normalized; - if (!aliases.TryGetValue(domain, out var exact)) - return normalized; - - return slash >= 0 ? exact + normalized[slash..] : exact; - } - - private static CanonicalConfidence MapConfidence(LiveIedDiscoveryConfidenceLevel confidence) - => confidence switch - { - LiveIedDiscoveryConfidenceLevel.Exact => CanonicalConfidence.Exact, - LiveIedDiscoveryConfidenceLevel.High => CanonicalConfidence.High, - LiveIedDiscoveryConfidenceLevel.Medium => CanonicalConfidence.Medium, - LiveIedDiscoveryConfidenceLevel.Low => CanonicalConfidence.Low, - _ => CanonicalConfidence.Unknown - }; - - private sealed class CanonicalIngressContext - { - public CanonicalIngressKind Ingress { get; init; } - public string SourceName { get; init; } = string.Empty; - public string SourceEdition { get; init; } = string.Empty; - public string IedName { get; init; } = string.Empty; - public string AccessPointName { get; init; } = string.Empty; - public CanonicalEvidenceSource IdentitySource { get; init; } - public CanonicalConfidence IdentityConfidence { get; init; } - public bool IdentityAmbiguous { get; init; } - public string[] IdentityCandidates { get; init; } = Array.Empty(); - public string[] IdentityEvidence { get; init; } = Array.Empty(); - public CanonicalEvidenceSource FactSource { get; init; } - public IReadOnlyDictionary DomainAliases { get; init; } = EmptyAliases; - public string[] OriginalTypeAliases { get; init; } = Array.Empty(); - } -} - MMS component separators while live discovery may expose + /// SCL commonly carries '$' MMS component separators while live discovery may expose /// the same configured resource with dots. Source projections retain their original /// spelling; the shared CanonicalIedModel does not. /// private static string NormalizeConfiguredReference( string value, IReadOnlyDictionary aliases) - => RemapReference(value, aliases).Replace(' { - var normalized = value?.Trim() ?? string.Empty; - return aliases.TryGetValue(normalized, out var exact) ? exact : normalized; - } - - private static string RemapReference(string value, IReadOnlyDictionary aliases) - { - var normalized = value?.Trim() ?? string.Empty; - if (normalized.Length == 0 || aliases.Count == 0) - return normalized; - - var slash = normalized.IndexOf('/'); - var domain = slash >= 0 ? normalized[..slash] : normalized; - if (!aliases.TryGetValue(domain, out var exact)) - return normalized; - - return slash >= 0 ? exact + normalized[slash..] : exact; - } - - private static CanonicalConfidence MapConfidence(LiveIedDiscoveryConfidenceLevel confidence) - => confidence switch - { - LiveIedDiscoveryConfidenceLevel.Exact => CanonicalConfidence.Exact, - LiveIedDiscoveryConfidenceLevel.High => CanonicalConfidence.High, - LiveIedDiscoveryConfidenceLevel.Medium => CanonicalConfidence.Medium, - LiveIedDiscoveryConfidenceLevel.Low => CanonicalConfidence.Low, - _ => CanonicalConfidence.Unknown - }; - - private sealed class CanonicalIngressContext - { - public CanonicalIngressKind Ingress { get; init; } - public string SourceName { get; init; } = string.Empty; - public string SourceEdition { get; init; } = string.Empty; - public string IedName { get; init; } = string.Empty; - public string AccessPointName { get; init; } = string.Empty; - public CanonicalEvidenceSource IdentitySource { get; init; } - public CanonicalConfidence IdentityConfidence { get; init; } - public bool IdentityAmbiguous { get; init; } - public string[] IdentityCandidates { get; init; } = Array.Empty(); - public string[] IdentityEvidence { get; init; } = Array.Empty(); - public CanonicalEvidenceSource FactSource { get; init; } - public IReadOnlyDictionary DomainAliases { get; init; } = EmptyAliases; - public string[] OriginalTypeAliases { get; init; } = Array.Empty(); - } -} -, '.'); + => RemapReference(value, aliases).Replace('$', '.'); private static string RemapDomain(string value, IReadOnlyDictionary aliases) { From ca41ca25fbbf64f7c426885893af01ed87938acc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:13:21 +0700 Subject: [PATCH 041/176] feat(reporting): add JIT configured-static readback proof policy (#144) --- .../MmsConfiguredStaticActivationVerifier.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs new file mode 100644 index 00000000..f32f3f90 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs @@ -0,0 +1,153 @@ +namespace AR.Iec61850.Mms; + +public enum MmsConfiguredStaticActivationProofKind +{ + Proven, + SnapshotReadFailed, + ReportEnableStateMismatch, + DataSetBindingMismatch, + InsufficientReadbackEvidence +} + +public sealed class MmsConfiguredStaticActivationProof +{ + public MmsConfiguredStaticActivationProofKind Kind { get; init; } + public string Stage { get; init; } = string.Empty; + public string ExpectedDataSetReference { get; init; } = string.Empty; + public string ObservedDataSetReference { get; init; } = string.Empty; + public string ObservedEnabledState { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + + public bool IsProven => Kind == MmsConfiguredStaticActivationProofKind.Proven; +} + +/// +/// Pure proof policy for configured-static RCB activation. +/// +/// Planning evidence can go stale between plan creation and mutation. This verifier therefore +/// treats whole-RCB readback as the just-in-time authority immediately before and after +/// activation. Missing DataSet text is not converted into a mismatch, but an explicit live +/// binding contradiction blocks. RptEna must be exact because activation ownership cannot be +/// inferred safely from a missing/ambiguous enable state. +/// +public static class MmsConfiguredStaticActivationVerifier +{ + public static MmsConfiguredStaticActivationProof VerifyBeforeWrite( + MmsReportRcbSnapshot snapshot, + string expectedDataSetReference) + { + ArgumentNullException.ThrowIfNull(snapshot); + + if (!snapshot.IsSuccess) + return Fail( + MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, + snapshot, + expectedDataSetReference, + "Pre-activation whole-RCB readback failed; no activation write is permitted."); + + if (HasPositiveDataSetMismatch(snapshot.DataSetReference, expectedDataSetReference)) + return Fail( + MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, + snapshot, + expectedDataSetReference, + "Live RCB DatSet contradicts the configured static plan; activation is blocked rather than rewriting DatSet."); + + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != false) + return Fail( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + snapshot, + expectedDataSetReference, + "Configured static activation requires exact RptEna=false immediately before mutation."); + + return Pass(snapshot, expectedDataSetReference, + "Pre-activation whole-RCB evidence proves RptEna=false and no conflicting live DataSet binding."); + } + + public static MmsConfiguredStaticActivationProof VerifyAfterEnable( + IReadOnlyList snapshots, + string expectedDataSetReference) + { + ArgumentNullException.ThrowIfNull(snapshots); + + if (snapshots.Count < 2) + { + return new MmsConfiguredStaticActivationProof + { + Kind = MmsConfiguredStaticActivationProofKind.InsufficientReadbackEvidence, + Stage = "after-enable", + ExpectedDataSetReference = Normalize(expectedDataSetReference), + Message = "Configured static activation requires two whole-RCB verification reads after RptEna=true." + }; + } + + foreach (var snapshot in snapshots) + { + if (!snapshot.IsSuccess) + return Fail( + MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, + snapshot, + expectedDataSetReference, + "Post-enable whole-RCB readback failed; write acceptance alone is not activation proof."); + + if (HasPositiveDataSetMismatch(snapshot.DataSetReference, expectedDataSetReference)) + return Fail( + MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, + snapshot, + expectedDataSetReference, + "Post-enable RCB DatSet contradicts the configured static plan."); + + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != true) + return Fail( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + snapshot, + expectedDataSetReference, + "RptEna=true was not proven by post-enable whole-RCB readback."); + } + + return Pass( + snapshots[^1], + expectedDataSetReference, + "Two post-enable whole-RCB reads prove RptEna=true with no conflicting live DataSet binding."); + } + + private static bool HasPositiveDataSetMismatch(string observed, string expected) + { + var observedNormalized = Normalize(observed); + var expectedNormalized = Normalize(expected); + return observedNormalized.Length > 0 && + expectedNormalized.Length > 0 && + !string.Equals(observedNormalized, expectedNormalized, StringComparison.OrdinalIgnoreCase); + } + + private static string Normalize(string? value) + => MmsRcbAvailabilityEvaluator.NormalizeReference(value); + + private static MmsConfiguredStaticActivationProof Pass( + MmsReportRcbSnapshot snapshot, + string expectedDataSetReference, + string message) + => new() + { + Kind = MmsConfiguredStaticActivationProofKind.Proven, + Stage = snapshot.Stage, + ExpectedDataSetReference = Normalize(expectedDataSetReference), + ObservedDataSetReference = Normalize(snapshot.DataSetReference), + ObservedEnabledState = snapshot.EnabledState, + Message = message + }; + + private static MmsConfiguredStaticActivationProof Fail( + MmsConfiguredStaticActivationProofKind kind, + MmsReportRcbSnapshot snapshot, + string expectedDataSetReference, + string message) + => new() + { + Kind = kind, + Stage = snapshot.Stage, + ExpectedDataSetReference = Normalize(expectedDataSetReference), + ObservedDataSetReference = Normalize(snapshot.DataSetReference), + ObservedEnabledState = snapshot.EnabledState, + Message = $"{message} {snapshot.Message}".Trim() + }; +} From c2c279953d3ce42b3d21d5031106fda702c8533e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:13:44 +0700 Subject: [PATCH 042/176] fix(reporting): fail closed when configured-static RptEna readback is unproven (#144) --- .../Mms/MmsClientSession.SclStaticReport.cs | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs index 5a5ec185..e20b37b6 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs @@ -101,10 +101,26 @@ public async Task StartConfiguredStaticRe try { - snapshots.Add(await CaptureReportControlSnapshotAsync( + var beforeSnapshot = await CaptureReportControlSnapshotAsync( rcb, - "scl-static-before-enable", - cancellationToken).ConfigureAwait(false)); + "configured-static-before-enable", + cancellationToken).ConfigureAwait(false); + snapshots.Add(beforeSnapshot); + + var beforeProof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite( + beforeSnapshot, + plan.DataSetReference); + if (!beforeProof.IsProven) + { + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = snapshots, + Message = $"Configured static activation failed JIT pre-write proof: {beforeProof.Kind}: {beforeProof.Message}" + }; + } monitor = new MmsPersistentReportMonitorSession( plan, @@ -185,14 +201,55 @@ public async Task StartConfiguredStaticRe monitor.EnabledByThisClient = true; - snapshots.Add(await CaptureReportControlSnapshotAsync( + var afterEnable1 = await CaptureReportControlSnapshotAsync( rcb, - "scl-static-after-enable-1", - cancellationToken).ConfigureAwait(false)); - snapshots.Add(await CaptureReportControlSnapshotAsync( + "configured-static-after-enable-1", + cancellationToken).ConfigureAwait(false); + var afterEnable2 = await CaptureReportControlSnapshotAsync( rcb, - "scl-static-after-enable-2", - cancellationToken).ConfigureAwait(false)); + "configured-static-after-enable-2", + cancellationToken).ConfigureAwait(false); + snapshots.Add(afterEnable1); + snapshots.Add(afterEnable2); + + var enableProof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable( + new[] { afterEnable1, afterEnable2 }, + plan.DataSetReference); + if (!enableProof.IsProven) + { + UnregisterPersistentReportMonitor(monitor); + + writes.Add(await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, + MmsReportControlSemanticWrite.ReportEnable(false), + CancellationToken.None).ConfigureAwait(false)); + enabled = false; + monitor.EnabledByThisClient = false; + + if (reservationTouched) + { + writes.Add(rcb.Buffered + ? await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, + MmsReportControlSemanticWrite.ReservationTime(0), + CancellationToken.None).ConfigureAwait(false) + : await TryWriteReportSemanticAttributeForCleanupAsync( + rcb, + MmsReportControlSemanticWrite.Reservation(false), + CancellationToken.None).ConfigureAwait(false)); + reservationTouched = false; + monitor.ReservationTouched = false; + } + + return new MmsPersistentReportMonitorStartResult + { + IsSuccess = false, + WriteSteps = writes, + Warnings = warnings, + RcbSnapshots = snapshots, + Message = $"Configured static activation failed post-enable proof: {enableProof.Kind}: {enableProof.Message}" + }; + } if (triggerGeneralInterrogation) { From ad6bfc5275d59ba12727ee2b68371313155cc7d2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:14:05 +0700 Subject: [PATCH 043/176] test(reporting): lock configured-static JIT readback proof contract (#144) --- ...ConfiguredStaticActivationVerifierTests.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs new file mode 100644 index 00000000..cbdf05e8 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs @@ -0,0 +1,106 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsConfiguredStaticActivationVerifierTests +{ + [Fact] + public void BeforeWrite_Requires_Exact_Disabled_Readback() + { + var unknown = Snapshot(success: true, enabled: "-", dataSet: "LD0/LLN0.DS01", stage: "before"); + var enabled = Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "before"); + var disabled = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before"); + + Assert.Equal( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(unknown, "LD0/LLN0.DS01").Kind); + Assert.Equal( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(enabled, "LD0/LLN0.DS01").Kind); + Assert.True( + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(disabled, "LD0/LLN0.DS01").IsProven); + } + + [Fact] + public void BeforeWrite_Blocks_Positive_DataSet_Contradiction_But_Not_Missing_Text() + { + var mismatch = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.Other", stage: "before"); + var missing = Snapshot(success: true, enabled: "false", dataSet: string.Empty, stage: "before"); + + Assert.Equal( + MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(mismatch, "LD0/LLN0.DS01").Kind); + Assert.True( + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(missing, "LD0/LLN0.DS01").IsProven); + } + + [Fact] + public void BeforeWrite_Fails_Closed_When_Whole_Rcb_Read_Fails() + { + var snapshot = Snapshot(success: false, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before"); + + var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(snapshot, "LD0/LLN0.DS01"); + + Assert.Equal(MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, proof.Kind); + Assert.False(proof.IsProven); + } + + [Fact] + public void AfterEnable_Requires_Two_Independent_Readbacks() + { + var one = new[] + { + Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "after-1") + }; + + var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(one, "LD0/LLN0.DS01"); + + Assert.Equal(MmsConfiguredStaticActivationProofKind.InsufficientReadbackEvidence, proof.Kind); + Assert.False(proof.IsProven); + } + + [Fact] + public void AfterEnable_Rejects_Write_Accepted_But_Readback_False() + { + var snapshots = new[] + { + Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "after-1"), + Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "after-2") + }; + + var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshots, "LD0/LLN0.DS01"); + + Assert.Equal(MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, proof.Kind); + Assert.False(proof.IsProven); + } + + [Fact] + public void AfterEnable_Proves_Activation_Only_When_Both_Readbacks_Agree() + { + var snapshots = new[] + { + Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "after-1"), + Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "after-2") + }; + + var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshots, "LD0/LLN0.DS01"); + + Assert.True(proof.IsProven); + Assert.Equal(MmsConfiguredStaticActivationProofKind.Proven, proof.Kind); + } + + private static MmsReportRcbSnapshot Snapshot( + bool success, + string enabled, + string dataSet, + string stage) + => new() + { + Stage = stage, + IsSuccess = success, + Reference = "LD0/LLN0.BR.B01", + EnabledState = enabled, + DataSetReference = dataSet, + Message = success ? "snapshot ok" : "snapshot failed" + }; +} From 97363b98b7c1401b590f655e53de81ca437091cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:34:41 +0700 Subject: [PATCH 044/176] perf(reporting): add minimal JIT RCB snapshot fast path (#144) --- ...entSession.ConfiguredStaticFastSnapshot.cs | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs new file mode 100644 index 00000000..ef0b1768 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticFastSnapshot.cs @@ -0,0 +1,175 @@ +namespace AR.Iec61850.Mms; + +/// +/// Hot-path RCB state capture for configured-static activation. +/// +/// Normal discovery/diagnostic probing intentionally reads many attributes. Activation must +/// not repeat that broad scan. This path attempts one complete RCB structure read first and +/// falls back only to the exact leaf evidence still required for the imminent mutation. +/// There are no sleeps and no unrelated RCB/DataSet scans. +/// +public sealed partial class MmsClientSession +{ + private async Task CaptureConfiguredStaticActivationSnapshotAsync( + MmsReportControlCandidate source, + string stage, + bool includeReservationEvidence, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(source); + + var clone = CloneReportControlCandidate(source); + clone.EnabledState = string.Empty; + clone.ReservationState = string.Empty; + clone.ReservationTimeSeconds = string.Empty; + clone.Owner = string.Empty; + clone.DataSetReference = string.Empty; + clone.DataSetProbeState = MmsRcbDataSetProbeState.NotAttempted; + clone.DataSetProbeMessage = string.Empty; + clone.ProbeDiagnostics.Clear(); + + var logicalReads = 0; + var structureRead = false; + var leafFallbacks = 0; + + try + { + var baseReference = MmsObjectReference.Parse( + source.Reference, + source.FunctionalConstraint); + var baseRead = await ReadSingleVariableAsync( + baseReference, + cancellationToken).ConfigureAwait(false); + logicalReads++; + + clone.ProbeDiagnostics.Add( + $"Activation RCB base {baseReference.Item}: {(baseRead.IsSuccess ? "OK" : baseRead.Message)}"); + + if (baseRead.IsSuccess && + baseRead.Value is { Kind: MmsDataKind.Structure } structure && + structure.Children.Count > 0) + { + ApplyReportControlStructure(clone, structure); + structureRead = true; + + // DatSet is element 2 for BRCB and element 3 for URCB in the standard RCB + // structure layouts used by ApplyReportControlStructure. + var dataSetIndex = source.Buffered ? 2 : 3; + if (structure.Children.Count > dataSetIndex) + { + clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded; + clone.DataSetProbeMessage = "DatSet observed in complete RCB structure read."; + } + } + + if (MmsRcbAvailabilityEvaluator.ParseBool(clone.EnabledState) is null) + { + leafFallbacks++; + logicalReads++; + await ProbeReportControlAttributeAsync( + clone, + "RptEna", + value => clone.EnabledState = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + } + + // A positive live DatSet contradiction is safety-relevant. If the complete + // structure did not provide the field, read only this one leaf. + if (clone.DataSetProbeState != MmsRcbDataSetProbeState.ReadSucceeded) + { + leafFallbacks++; + logicalReads++; + var beforeDiagnostics = clone.ProbeDiagnostics.Count; + await ProbeReportControlAttributeAsync( + clone, + "DatSet", + value => + { + var text = NormalizeReportAttributeText(value); + clone.DataSetReference = string.IsNullOrWhiteSpace(text) + ? string.Empty + : NormalizeReportedDataSetReference(clone.Domain, text); + }, + cancellationToken).ConfigureAwait(false); + + var datSetDiagnostic = clone.ProbeDiagnostics + .Skip(beforeDiagnostics) + .LastOrDefault(line => line.StartsWith("DatSet", StringComparison.OrdinalIgnoreCase)); + if (datSetDiagnostic?.Contains(": OK ", StringComparison.OrdinalIgnoreCase) == true) + { + clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded; + clone.DataSetProbeMessage = datSetDiagnostic; + } + else + { + clone.DataSetProbeState = MmsRcbDataSetProbeState.ReadFailed; + clone.DataSetProbeMessage = datSetDiagnostic ?? "DatSet activation leaf read did not produce exact evidence."; + } + } + + if (includeReservationEvidence) + { + if (source.Buffered) + { + if (MmsRcbAvailabilityEvaluator.ParseUnsigned(clone.ReservationTimeSeconds) is null && + source.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) + { + leafFallbacks++; + logicalReads++; + await ProbeReportControlAttributeAsync( + clone, + "ResvTms", + value => clone.ReservationTimeSeconds = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + } + } + else if (MmsRcbAvailabilityEvaluator.ParseBool(clone.ReservationState) is null && + source.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) + { + leafFallbacks++; + logicalReads++; + await ProbeReportControlAttributeAsync( + clone, + "Resv", + value => clone.ReservationState = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + } + + // Owner is a separate optional leaf on many IEDs. Avoid paying this RTT + // when explicit reservation evidence is already free. Probe it only when + // reservation evidence remains ambiguous or earlier evidence was positive. + var explicitFree = source.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(clone.ReservationTimeSeconds) == 0 + : MmsRcbAvailabilityEvaluator.ParseBool(clone.ReservationState) == false; + var earlierPositiveOwner = MmsRcbAvailabilityEvaluator.HasOwner(source.Owner); + if (source.Attributes.Contains("Owner", StringComparer.OrdinalIgnoreCase) && + (!explicitFree || earlierPositiveOwner)) + { + leafFallbacks++; + logicalReads++; + await ProbeOwnerReadOnlyAsync(clone, cancellationToken).ConfigureAwait(false); + } + } + + var hasExactEnable = MmsRcbAvailabilityEvaluator.ParseBool(clone.EnabledState).HasValue; + var message = + $"Configured-static JIT snapshot: exactRptEna={hasExactEnable.ToString().ToLowerInvariant()}, " + + $"structureRead={structureRead.ToString().ToLowerInvariant()}, logicalReads={logicalReads}, " + + $"leafFallbacks={leafFallbacks}, dataSetProbe={clone.DataSetProbeState}."; + + return MmsReportRcbSnapshot.FromCandidate( + stage, + clone, + success: hasExactEnable, + message); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return MmsReportRcbSnapshot.FromCandidate( + stage, + clone, + success: false, + $"Configured-static JIT snapshot failed after logicalReads={logicalReads}: {ex.GetType().Name}: {ex.Message}"); + } + } +} From ac6094e523c1e3453976fcf1e92dc00adac6fc78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:34:53 +0700 Subject: [PATCH 045/176] feat(reporting): retain exact JIT RCB state evidence in snapshots (#144) --- src/AR.Iec61850/Mms/MmsReportLiveSession.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs index 08047c57..3239a7c6 100644 --- a/src/AR.Iec61850/Mms/MmsReportLiveSession.cs +++ b/src/AR.Iec61850/Mms/MmsReportLiveSession.cs @@ -231,12 +231,16 @@ public sealed class MmsReportRcbSnapshot public string Message { get; init; } = string.Empty; public string Reference { get; init; } = string.Empty; public string Mode { get; init; } = string.Empty; + public bool Buffered { get; init; } public string DataSetReference { get; init; } = string.Empty; + public MmsRcbDataSetProbeState DataSetProbeState { get; init; } = MmsRcbDataSetProbeState.NotAttempted; + public string DataSetProbeMessage { get; init; } = string.Empty; public string ReportId { get; init; } = string.Empty; public string ConfRev { get; init; } = string.Empty; public string EnabledState { get; init; } = string.Empty; public string ReservationState { get; init; } = string.Empty; public string ReservationTimeSeconds { get; init; } = string.Empty; + public string Owner { get; init; } = string.Empty; public string BufferTimeMs { get; init; } = string.Empty; public string IntegrityPeriodMs { get; init; } = string.Empty; public string TriggerOptions { get; init; } = string.Empty; @@ -256,12 +260,16 @@ internal static MmsReportRcbSnapshot FromCandidate(string stage, MmsReportContro Message = message, Reference = candidate.Reference, Mode = candidate.Mode, + Buffered = candidate.Buffered, DataSetReference = candidate.DataSetReference, + DataSetProbeState = candidate.DataSetProbeState, + DataSetProbeMessage = candidate.DataSetProbeMessage, ReportId = candidate.ReportId, ConfRev = candidate.ConfRev, EnabledState = candidate.EnabledState, ReservationState = candidate.ReservationState, ReservationTimeSeconds = candidate.ReservationTimeSeconds, + Owner = candidate.Owner, BufferTimeMs = candidate.BufferTimeMs, IntegrityPeriodMs = candidate.IntegrityPeriodMs, TriggerOptions = candidate.TriggerOptions, From 7c6f3f63dbb3d5b4e9ec5236369ece7f4c7e243c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:35:18 +0700 Subject: [PATCH 046/176] perf(reporting): prove configured static activation with one exact readback (#144) --- .../MmsConfiguredStaticActivationVerifier.cs | 115 ++++++++++-------- 1 file changed, 67 insertions(+), 48 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs index f32f3f90..232a4f56 100644 --- a/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs +++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs @@ -6,6 +6,7 @@ public enum MmsConfiguredStaticActivationProofKind SnapshotReadFailed, ReportEnableStateMismatch, DataSetBindingMismatch, + BusyRuntimeEvidence, InsufficientReadbackEvidence } @@ -24,11 +25,14 @@ public sealed class MmsConfiguredStaticActivationProof /// /// Pure proof policy for configured-static RCB activation. /// -/// Planning evidence can go stale between plan creation and mutation. This verifier therefore -/// treats whole-RCB readback as the just-in-time authority immediately before and after -/// activation. Missing DataSet text is not converted into a mismatch, but an explicit live -/// binding contradiction blocks. RptEna must be exact because activation ownership cannot be -/// inferred safely from a missing/ambiguous enable state. +/// Planning evidence can go stale between plan creation and mutation. This verifier treats +/// a minimal just-in-time RCB snapshot as the runtime authority. One exact post-write +/// readback is sufficient when it proves RptEna=true and no positive DataSet contradiction; +/// a second unconditional read would add latency without adding semantic evidence. +/// +/// Missing reservation/Owner metadata is not converted into a blocker, but positive busy +/// evidence always blocks. Missing DataSet text is not a mismatch unless the live DatSet +/// read positively succeeded and proved an empty binding. /// public static class MmsConfiguredStaticActivationVerifier { @@ -43,9 +47,9 @@ public static MmsConfiguredStaticActivationProof VerifyBeforeWrite( MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, snapshot, expectedDataSetReference, - "Pre-activation whole-RCB readback failed; no activation write is permitted."); + "Pre-activation JIT RCB read failed; no activation write is permitted."); - if (HasPositiveDataSetMismatch(snapshot.DataSetReference, expectedDataSetReference)) + if (HasPositiveDataSetMismatch(snapshot, expectedDataSetReference)) return Fail( MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, snapshot, @@ -59,61 +63,76 @@ public static MmsConfiguredStaticActivationProof VerifyBeforeWrite( expectedDataSetReference, "Configured static activation requires exact RptEna=false immediately before mutation."); - return Pass(snapshot, expectedDataSetReference, - "Pre-activation whole-RCB evidence proves RptEna=false and no conflicting live DataSet binding."); + if (HasPositiveBusyEvidence(snapshot)) + return Fail( + MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence, + snapshot, + expectedDataSetReference, + "Fresh reservation/Owner evidence indicates the RCB is occupied by another runtime context."); + + return Pass( + snapshot, + expectedDataSetReference, + "Pre-activation JIT evidence proves RptEna=false, no conflicting DataSet binding, and no positive busy evidence."); } public static MmsConfiguredStaticActivationProof VerifyAfterEnable( - IReadOnlyList snapshots, + MmsReportRcbSnapshot snapshot, string expectedDataSetReference) { - ArgumentNullException.ThrowIfNull(snapshots); + ArgumentNullException.ThrowIfNull(snapshot); - if (snapshots.Count < 2) - { - return new MmsConfiguredStaticActivationProof - { - Kind = MmsConfiguredStaticActivationProofKind.InsufficientReadbackEvidence, - Stage = "after-enable", - ExpectedDataSetReference = Normalize(expectedDataSetReference), - Message = "Configured static activation requires two whole-RCB verification reads after RptEna=true." - }; - } + if (!snapshot.IsSuccess) + return Fail( + MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, + snapshot, + expectedDataSetReference, + "Post-enable JIT readback failed; write acceptance alone is not activation proof."); - foreach (var snapshot in snapshots) - { - if (!snapshot.IsSuccess) - return Fail( - MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, - snapshot, - expectedDataSetReference, - "Post-enable whole-RCB readback failed; write acceptance alone is not activation proof."); - - if (HasPositiveDataSetMismatch(snapshot.DataSetReference, expectedDataSetReference)) - return Fail( - MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, - snapshot, - expectedDataSetReference, - "Post-enable RCB DatSet contradicts the configured static plan."); - - if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != true) - return Fail( - MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, - snapshot, - expectedDataSetReference, - "RptEna=true was not proven by post-enable whole-RCB readback."); - } + if (HasPositiveDataSetMismatch(snapshot, expectedDataSetReference)) + return Fail( + MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, + snapshot, + expectedDataSetReference, + "Post-enable RCB DatSet contradicts the configured static plan."); + + if (MmsRcbAvailabilityEvaluator.ParseBool(snapshot.EnabledState) != true) + return Fail( + MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, + snapshot, + expectedDataSetReference, + "RptEna=true was not proven by post-enable JIT readback."); return Pass( - snapshots[^1], + snapshot, expectedDataSetReference, - "Two post-enable whole-RCB reads prove RptEna=true with no conflicting live DataSet binding."); + "Post-enable JIT readback proves RptEna=true with no conflicting live DataSet binding."); } - private static bool HasPositiveDataSetMismatch(string observed, string expected) + private static bool HasPositiveBusyEvidence(MmsReportRcbSnapshot snapshot) { - var observedNormalized = Normalize(observed); + if (MmsRcbAvailabilityEvaluator.HasOwner(snapshot.Owner)) + return true; + + return snapshot.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0 + : MmsRcbAvailabilityEvaluator.ParseBool(snapshot.ReservationState) == true; + } + + private static bool HasPositiveDataSetMismatch( + MmsReportRcbSnapshot snapshot, + string expected) + { + var observedNormalized = Normalize(snapshot.DataSetReference); var expectedNormalized = Normalize(expected); + + if (snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded && + observedNormalized.Length == 0 && + expectedNormalized.Length > 0) + { + return true; + } + return observedNormalized.Length > 0 && expectedNormalized.Length > 0 && !string.Equals(observedNormalized, expectedNormalized, StringComparison.OrdinalIgnoreCase); From 86915ca33f08417433f59d68d44bafe2cafc44fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:35:33 +0700 Subject: [PATCH 047/176] perf(reporting): use minimal JIT preflight and single exact post-enable proof (#144) --- .../Mms/MmsClientSession.SclStaticReport.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs index e20b37b6..f2bfdebd 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs @@ -101,9 +101,10 @@ public async Task StartConfiguredStaticRe try { - var beforeSnapshot = await CaptureReportControlSnapshotAsync( + var beforeSnapshot = await CaptureConfiguredStaticActivationSnapshotAsync( rcb, "configured-static-before-enable", + includeReservationEvidence: true, cancellationToken).ConfigureAwait(false); snapshots.Add(beforeSnapshot); @@ -201,19 +202,15 @@ public async Task StartConfiguredStaticRe monitor.EnabledByThisClient = true; - var afterEnable1 = await CaptureReportControlSnapshotAsync( + var afterEnable = await CaptureConfiguredStaticActivationSnapshotAsync( rcb, - "configured-static-after-enable-1", + "configured-static-after-enable", + includeReservationEvidence: false, cancellationToken).ConfigureAwait(false); - var afterEnable2 = await CaptureReportControlSnapshotAsync( - rcb, - "configured-static-after-enable-2", - cancellationToken).ConfigureAwait(false); - snapshots.Add(afterEnable1); - snapshots.Add(afterEnable2); + snapshots.Add(afterEnable); var enableProof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable( - new[] { afterEnable1, afterEnable2 }, + afterEnable, plan.DataSetReference); if (!enableProof.IsProven) { From f7675a6694110fb519a4944b75a18f1e611ae5ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:35:54 +0700 Subject: [PATCH 048/176] test(reporting): lock minimal one-readback configured-static proof (#144) --- ...ConfiguredStaticActivationVerifierTests.cs | 100 ++++++++++++------ 1 file changed, 66 insertions(+), 34 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs index cbdf05e8..157e45b2 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs @@ -22,85 +22,117 @@ public void BeforeWrite_Requires_Exact_Disabled_Readback() } [Fact] - public void BeforeWrite_Blocks_Positive_DataSet_Contradiction_But_Not_Missing_Text() + public void BeforeWrite_Blocks_Positive_DataSet_Contradiction_But_Not_Unread_DataSet() { - var mismatch = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.Other", stage: "before"); - var missing = Snapshot(success: true, enabled: "false", dataSet: string.Empty, stage: "before"); + var mismatch = Snapshot( + success: true, + enabled: "false", + dataSet: "LD0/LLN0.Other", + stage: "before", + dataSetProbeState: MmsRcbDataSetProbeState.ReadSucceeded); + var unread = Snapshot( + success: true, + enabled: "false", + dataSet: string.Empty, + stage: "before", + dataSetProbeState: MmsRcbDataSetProbeState.ReadFailed); Assert.Equal( MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(mismatch, "LD0/LLN0.DS01").Kind); Assert.True( - MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(missing, "LD0/LLN0.DS01").IsProven); + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(unread, "LD0/LLN0.DS01").IsProven); } [Fact] - public void BeforeWrite_Fails_Closed_When_Whole_Rcb_Read_Fails() + public void BeforeWrite_Blocks_Positive_Empty_Live_DataSet() { - var snapshot = Snapshot(success: false, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before"); + var empty = Snapshot( + success: true, + enabled: "false", + dataSet: string.Empty, + stage: "before", + dataSetProbeState: MmsRcbDataSetProbeState.ReadSucceeded); - var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(snapshot, "LD0/LLN0.DS01"); + var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(empty, "LD0/LLN0.DS01"); - Assert.Equal(MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, proof.Kind); - Assert.False(proof.IsProven); + Assert.Equal(MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, proof.Kind); } [Fact] - public void AfterEnable_Requires_Two_Independent_Readbacks() + public void BeforeWrite_Blocks_Positive_Busy_Evidence() { - var one = new[] - { - Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "after-1") - }; + var brcb = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before", buffered: true); + brcb = brcb with { ReservationTimeSeconds = "30" }; - var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(one, "LD0/LLN0.DS01"); + var urcb = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before", buffered: false); + urcb = urcb with { ReservationState = "true" }; - Assert.Equal(MmsConfiguredStaticActivationProofKind.InsufficientReadbackEvidence, proof.Kind); - Assert.False(proof.IsProven); + Assert.Equal( + MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(brcb, "LD0/LLN0.DS01").Kind); + Assert.Equal( + MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence, + MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(urcb, "LD0/LLN0.DS01").Kind); } [Fact] - public void AfterEnable_Rejects_Write_Accepted_But_Readback_False() + public void BeforeWrite_Fails_Closed_When_Jit_Read_Fails() { - var snapshots = new[] - { - Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "after-1"), - Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "after-2") - }; + var snapshot = Snapshot(success: false, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before"); - var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshots, "LD0/LLN0.DS01"); + var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(snapshot, "LD0/LLN0.DS01"); - Assert.Equal(MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, proof.Kind); + Assert.Equal(MmsConfiguredStaticActivationProofKind.SnapshotReadFailed, proof.Kind); Assert.False(proof.IsProven); } [Fact] - public void AfterEnable_Proves_Activation_Only_When_Both_Readbacks_Agree() + public void AfterEnable_One_Exact_Readback_Is_Sufficient() { - var snapshots = new[] - { - Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "after-1"), - Snapshot(success: true, enabled: "true", dataSet: "LD0/LLN0.DS01", stage: "after-2") - }; + var snapshot = Snapshot( + success: true, + enabled: "true", + dataSet: "LD0/LLN0.DS01", + stage: "after-enable"); - var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshots, "LD0/LLN0.DS01"); + var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshot, "LD0/LLN0.DS01"); Assert.True(proof.IsProven); Assert.Equal(MmsConfiguredStaticActivationProofKind.Proven, proof.Kind); } + [Fact] + public void AfterEnable_Rejects_Write_Accepted_But_Readback_False() + { + var snapshot = Snapshot( + success: true, + enabled: "false", + dataSet: "LD0/LLN0.DS01", + stage: "after-enable"); + + var proof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(snapshot, "LD0/LLN0.DS01"); + + Assert.Equal(MmsConfiguredStaticActivationProofKind.ReportEnableStateMismatch, proof.Kind); + Assert.False(proof.IsProven); + } + private static MmsReportRcbSnapshot Snapshot( bool success, string enabled, string dataSet, - string stage) + string stage, + bool buffered = true, + MmsRcbDataSetProbeState dataSetProbeState = MmsRcbDataSetProbeState.NotAttempted) => new() { Stage = stage, IsSuccess = success, - Reference = "LD0/LLN0.BR.B01", + Reference = buffered ? "LD0/LLN0.BR.B01" : "LD0/LLN0.RP.U01", + Buffered = buffered, EnabledState = enabled, DataSetReference = dataSet, + DataSetProbeState = dataSetProbeState, Message = success ? "snapshot ok" : "snapshot failed" }; } From 12bb6bbc83518092e6bcb3c904722acfc5f337d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:36:07 +0700 Subject: [PATCH 049/176] test(reporting): fix busy-evidence fixture construction (#144) --- ...ConfiguredStaticActivationVerifierTests.cs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs index 157e45b2..e69606b5 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs @@ -62,11 +62,21 @@ public void BeforeWrite_Blocks_Positive_Empty_Live_DataSet() [Fact] public void BeforeWrite_Blocks_Positive_Busy_Evidence() { - var brcb = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before", buffered: true); - brcb = brcb with { ReservationTimeSeconds = "30" }; + var brcb = Snapshot( + success: true, + enabled: "false", + dataSet: "LD0/LLN0.DS01", + stage: "before", + buffered: true, + reservationTimeSeconds: "30"); - var urcb = Snapshot(success: true, enabled: "false", dataSet: "LD0/LLN0.DS01", stage: "before", buffered: false); - urcb = urcb with { ReservationState = "true" }; + var urcb = Snapshot( + success: true, + enabled: "false", + dataSet: "LD0/LLN0.DS01", + stage: "before", + buffered: false, + reservationState: "true"); Assert.Equal( MmsConfiguredStaticActivationProofKind.BusyRuntimeEvidence, @@ -123,7 +133,10 @@ private static MmsReportRcbSnapshot Snapshot( string dataSet, string stage, bool buffered = true, - MmsRcbDataSetProbeState dataSetProbeState = MmsRcbDataSetProbeState.NotAttempted) + MmsRcbDataSetProbeState dataSetProbeState = MmsRcbDataSetProbeState.NotAttempted, + string reservationState = "", + string reservationTimeSeconds = "", + string owner = "") => new() { Stage = stage, @@ -133,6 +146,9 @@ private static MmsReportRcbSnapshot Snapshot( EnabledState = enabled, DataSetReference = dataSet, DataSetProbeState = dataSetProbeState, + ReservationState = reservationState, + ReservationTimeSeconds = reservationTimeSeconds, + Owner = owner, Message = success ? "snapshot ok" : "snapshot failed" }; } From 327d499bb3093a72586c89801e542a9cd2ec30c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:40:34 +0700 Subject: [PATCH 050/176] ci(#144): overlay targeted hot-path slice --- ...entSession.ConfiguredStaticDataSetCache.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs new file mode 100644 index 00000000..76729fcc --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.ConfiguredStaticDataSetCache.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; + +namespace AR.Iec61850.Mms; + +/// +/// Association-scoped cache for immutable configured DataSet directories. +/// +/// Only successfully read non-deletable DataSets are cached. Dynamic/deletable or +/// unknown-mutability DataSets are never cached because their membership can change. +/// The cache is cleared on every successful MMS association. +/// +public sealed partial class MmsClientSession +{ + private readonly ConcurrentDictionary _configuredStaticDataSetDirectoryEvidence = + new(StringComparer.OrdinalIgnoreCase); + + private void ResetConfiguredStaticDataSetDirectoryEvidence() + => _configuredStaticDataSetDirectoryEvidence.Clear(); + + private async Task<(MmsDataSetDirectoryResult Result, bool CacheHit)> GetAvailabilityDataSetDirectoryAsync( + string dataSetReference, + MmsIedModelDirectory? directory, + CancellationToken cancellationToken) + { + var key = MmsRcbAvailabilityEvaluator.NormalizeReference(dataSetReference); + if (key.Length > 0 && + _configuredStaticDataSetDirectoryEvidence.TryGetValue(key, out var cached)) + { + return (cached, true); + } + + var result = await GetDataSetDirectoryAsync( + dataSetReference, + directory, + cancellationToken).ConfigureAwait(false); + + if (key.Length > 0 && + result.IsSuccess && + result.IsDeletable == false) + { + _configuredStaticDataSetDirectoryEvidence[key] = result; + } + + return (result, false); + } +} From 048d39dbd84d4f3d5542f4c81dbc9d55ad727cc6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:40:37 +0700 Subject: [PATCH 051/176] ci(#144): overlay targeted hot-path slice --- .../Mms/MmsClientSession.RcbAvailability.cs | 65 +++++++++++++------ 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs index 22c40a57..75f973a3 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs @@ -16,39 +16,49 @@ public async Task CheckReportControlAvailabilityAsync( var warnings = new List(); var snapshots = new List(); var dataSetDirectories = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rcbStateLogicalReads = 0; + var dataSetDirectoryNetworkReads = 0; + var dataSetDirectoryCacheHits = 0; + var callerOwned = options.CallerOwnedRcbReferences .Select(MmsRcbAvailabilityEvaluator.NormalizeReference) .Where(reference => reference.Length > 0) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var max = Math.Clamp(options.MaxReportControls, 1, 4096); - var candidates = inventory.ReportControls - .OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference)) - .ThenByDescending(candidate => candidate.Buffered) - .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase) - .ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) - .Take(max) - .ToArray(); - - if (inventory.ReportControls.Count > candidates.Length) - warnings.Add($"Availability check was bounded to {candidates.Length} of {inventory.ReportControls.Count} discovered RCBs."); - - foreach (var source in candidates) + + var selection = MmsRcbAvailabilityTargetSelector.Select(inventory, options); + warnings.AddRange(selection.Warnings); + + foreach (var source in selection.Candidates) { cancellationToken.ThrowIfCancellationRequested(); var candidate = CloneReportControl(source); - // Preserve discovery/SCL-derived binding only as fallback evidence. Clear the - // candidate before the forced live probe so a successful empty DatSet read can - // be distinguished from a failed read that merely left an old value in memory. + // Preserve discovery/SCL-derived binding only as fallback evidence. Volatile + // runtime state is cleared before every live check so stale discovery values + // can never masquerade as just-in-time availability evidence. var previouslyKnownDataSetReference = candidate.DataSetReference; candidate.DataSetReference = string.Empty; candidate.DataSetProbeState = MmsRcbDataSetProbeState.NotAttempted; candidate.DataSetProbeMessage = string.Empty; + candidate.EnabledState = string.Empty; + candidate.ReservationState = string.Empty; + candidate.ReservationTimeSeconds = string.Empty; + candidate.Owner = string.Empty; + + if (selection.TargetFilterApplied) + { + rcbStateLogicalReads += await ProbeTargetedReportControlAvailabilityAsync( + candidate, + cancellationToken).ConfigureAwait(false); + } + else + { + await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false); + // Broad diagnostic mode intentionally retains full attribute/Owner evidence. + await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false); + } - await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false); CaptureDataSetProbeEvidence(candidate, previouslyKnownDataSetReference); - await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false); MmsDataSetDirectoryResult? dataSetDirectory = null; var dataSetReference = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference); @@ -56,7 +66,15 @@ public async Task CheckReportControlAvailabilityAsync( { if (!dataSetDirectories.TryGetValue(dataSetReference, out dataSetDirectory)) { - dataSetDirectory = await GetDataSetDirectoryAsync(candidate.DataSetReference, directory, cancellationToken).ConfigureAwait(false); + var directoryEvidence = await GetAvailabilityDataSetDirectoryAsync( + candidate.DataSetReference, + directory, + cancellationToken).ConfigureAwait(false); + dataSetDirectory = directoryEvidence.Result; + if (directoryEvidence.CacheHit) + dataSetDirectoryCacheHits++; + else + dataSetDirectoryNetworkReads++; dataSetDirectories[dataSetReference] = dataSetDirectory; } } @@ -71,6 +89,13 @@ public async Task CheckReportControlAvailabilityAsync( return new MmsRcbAvailabilityResult { CheckedAtUtc = checkedAt, + InventoryReportControlCount = inventory.ReportControls.Count, + TargetFilterApplied = selection.TargetFilterApplied, + RequestedTargetReportControlCount = selection.RequestedTargetCount, + MatchedTargetReportControlCount = selection.MatchedTargetCount, + RcbStateLogicalReadCount = rcbStateLogicalReads, + DataSetDirectoryNetworkReadCount = dataSetDirectoryNetworkReads, + DataSetDirectoryCacheHitCount = dataSetDirectoryCacheHits, ReportControls = snapshots .OrderByDescending(item => item.DataSetMemberCount > 0) .ThenBy(item => AvailabilityRank(item.Availability)) From 1b64dfbba1daf99262c897937995a2e10bbe63c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:40:41 +0700 Subject: [PATCH 052/176] ci(#144): overlay targeted hot-path slice --- ...msClientSession.TargetedRcbAvailability.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs diff --git a/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs new file mode 100644 index 00000000..940a75bc --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs @@ -0,0 +1,86 @@ +namespace AR.Iec61850.Mms; + +/// +/// Minimal live-state probe used only after an exact RCB target has already been chosen. +/// +/// The broad diagnostic probe reads many presentation/configuration attributes. The runtime +/// hot path instead tries one complete RCB structure read, then fills only missing DatSet, +/// RptEna, reservation and Owner evidence required for safe availability decisions. +/// +public sealed partial class MmsClientSession +{ + private async Task ProbeTargetedReportControlAvailabilityAsync( + MmsReportControlCandidate reportControl, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reportControl); + + var logicalReads = 0; + + // Prefer one complete RCB structure read. On interoperable peers this provides + // DatSet + RptEna + reservation state in one confirmed service round trip. + await TryReadReportControlStructureAsync(reportControl, cancellationToken).ConfigureAwait(false); + logicalReads++; + + if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.EnabledState) is null) + { + await ProbeReportControlAttributeAsync( + reportControl, + "RptEna", + value => reportControl.EnabledState = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + logicalReads++; + } + + if (string.IsNullOrWhiteSpace(reportControl.DataSetReference)) + { + await ProbeReportControlAttributeAsync( + reportControl, + "DatSet", + value => + { + var text = NormalizeReportAttributeText(value); + reportControl.DataSetReference = string.IsNullOrWhiteSpace(text) + ? string.Empty + : NormalizeReportedDataSetReference(reportControl.Domain, text); + }, + cancellationToken).ConfigureAwait(false); + logicalReads++; + } + + if (reportControl.Buffered) + { + if (MmsRcbAvailabilityEvaluator.ParseUnsigned(reportControl.ReservationTimeSeconds) is null && + reportControl.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase)) + { + await ProbeReportControlAttributeAsync( + reportControl, + "ResvTms", + value => reportControl.ReservationTimeSeconds = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + logicalReads++; + } + } + else if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.ReservationState) is null && + reportControl.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) + { + await ProbeReportControlAttributeAsync( + reportControl, + "Resv", + value => reportControl.ReservationState = NormalizeReportAttributeText(value), + cancellationToken).ConfigureAwait(false); + logicalReads++; + } + + // Owner is outside the base structure on many devices. If exposed, one exact + // read is cheaper and safer than inferring ownership from missing metadata. + if (reportControl.Attributes.Contains("Owner", StringComparer.OrdinalIgnoreCase)) + { + await ProbeOwnerReadOnlyAsync(reportControl, cancellationToken).ConfigureAwait(false); + logicalReads++; + } + + reportControl.Status = "Targeted-availability-probed"; + return logicalReads; + } +} From b33ce7f256789d3314ea23eaf0e5da948bc47600 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:40:45 +0700 Subject: [PATCH 053/176] ci(#144): overlay targeted hot-path slice --- src/AR.Iec61850/Mms/MmsClientSession.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs index d84e22c5..636182e7 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.cs @@ -766,6 +766,7 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation State = MmsAssociationState.MmsInitiated; LastHandshakeMessage = result.Message; ResetReportSemanticTypeEvidence(); + ResetConfiguredStaticDataSetDirectoryEvidence(); _receivePump.Start(cancellationToken); return; } From 7ad1193522e0a65e5a64f852bc4f59f4712ec815 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:40:49 +0700 Subject: [PATCH 054/176] ci(#144): overlay targeted hot-path slice --- src/AR.Iec61850/Mms/MmsRcbAvailability.cs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs index d916a64f..f0cbc897 100644 --- a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs +++ b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs @@ -22,6 +22,15 @@ public sealed class MmsRcbAvailabilityOptions { public int MaxReportControls { get; init; } = 512; public bool ReadDataSetDirectories { get; init; } = true; + + /// + /// Optional exact live RCB references to probe. Empty means broad diagnostic mode. + /// Runtime acquisition hot paths should supply only the concrete RCB instances that + /// canonical coverage/family resolution has already selected. + /// + public IReadOnlySet TargetReportControlReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + public IReadOnlySet CallerOwnedRcbReferences { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); } @@ -70,10 +79,21 @@ public sealed class MmsRcbAvailabilityResult public IReadOnlyList ReportControls { get; init; } = Array.Empty(); public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public int InventoryReportControlCount { get; init; } + public bool TargetFilterApplied { get; init; } + public int RequestedTargetReportControlCount { get; init; } + public int MatchedTargetReportControlCount { get; init; } + public int RcbStateLogicalReadCount { get; init; } + public int DataSetDirectoryNetworkReadCount { get; init; } + public int DataSetDirectoryCacheHitCount { get; init; } + public int AvailableCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Available); public int InUseCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.InUse); public int UnknownCount => ReportControls.Count(item => item.Availability == MmsRcbOperationalAvailability.Unknown); - public string Summary => $"RCB availability checked: total={ReportControls.Count}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}."; + public string Summary => + $"RCB availability checked: inventory={InventoryReportControlCount}, probed={ReportControls.Count}, " + + $"targeted={TargetFilterApplied.ToString().ToLowerInvariant()}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}, " + + $"rcbStateReads={RcbStateLogicalReadCount}, datasetReads={DataSetDirectoryNetworkReadCount}, datasetCacheHits={DataSetDirectoryCacheHitCount}."; } public static class MmsRcbAvailabilityEvaluator From e78f007ff9f89343fc90f71e04ee9058bbcbe7c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:40:53 +0700 Subject: [PATCH 055/176] ci(#144): overlay targeted hot-path slice --- .../Mms/MmsRcbAvailabilityTargetSelector.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs b/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs new file mode 100644 index 00000000..4963505c --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsRcbAvailabilityTargetSelector.cs @@ -0,0 +1,75 @@ +namespace AR.Iec61850.Mms; + +public sealed class MmsRcbAvailabilitySelection +{ + public IReadOnlyList Candidates { get; init; } = Array.Empty(); + public bool TargetFilterApplied { get; init; } + public int RequestedTargetCount { get; init; } + public int MatchedTargetCount { get; init; } + public int EligibleCountBeforeLimit { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); +} + +/// +/// Pure candidate selector for availability probing. +/// +/// Exact target references are never broadened to family/neighbor RCBs. Family resolution +/// belongs to the canonical/report-family planner before this stage. This keeps the network +/// hot path proportional to the RCBs that can actually serve the selected signals. +/// +public static class MmsRcbAvailabilityTargetSelector +{ + public static MmsRcbAvailabilitySelection Select( + MmsReportInventory inventory, + MmsRcbAvailabilityOptions options) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(options); + + var warnings = new List(); + var targets = options.TargetReportControlReferences + .Select(MmsRcbAvailabilityEvaluator.NormalizeReference) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targeted = targets.Count > 0; + + IEnumerable eligible = inventory.ReportControls; + if (targeted) + { + eligible = eligible.Where(candidate => + targets.Contains(MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference))); + } + + var eligibleArray = eligible.ToArray(); + var matched = targeted ? eligibleArray.Length : 0; + if (targeted && matched < targets.Count) + { + warnings.Add( + $"Targeted RCB availability matched {matched} of {targets.Count} requested exact live reference(s); " + + "unmatched targets were not broadened to unrelated RCBs."); + } + + var max = Math.Clamp(options.MaxReportControls, 1, 4096); + var candidates = eligibleArray + .OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference)) + .ThenByDescending(candidate => candidate.Buffered) + .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) + .Take(max) + .ToArray(); + + if (eligibleArray.Length > candidates.Length) + warnings.Add($"Availability check was bounded to {candidates.Length} of {eligibleArray.Length} eligible RCBs."); + + return new MmsRcbAvailabilitySelection + { + Candidates = candidates, + TargetFilterApplied = targeted, + RequestedTargetCount = targets.Count, + MatchedTargetCount = matched, + EligibleCountBeforeLimit = eligibleArray.Length, + Warnings = warnings + }; + } +} From d129faae3b9d3a5965906187eaad6d834a73a3b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:40:56 +0700 Subject: [PATCH 056/176] ci(#144): overlay targeted hot-path slice --- .../MmsRcbAvailabilityTargetSelectorTests.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs new file mode 100644 index 00000000..cd5ed253 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsRcbAvailabilityTargetSelectorTests.cs @@ -0,0 +1,123 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsRcbAvailabilityTargetSelectorTests +{ + [Fact] + public void Empty_Target_Set_Preserves_Broad_Diagnostic_Mode() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.B01", buffered: true), + Candidate("LD0/LLN0.RP.U01", buffered: false)); + + var selection = MmsRcbAvailabilityTargetSelector.Select( + inventory, + new MmsRcbAvailabilityOptions()); + + Assert.False(selection.TargetFilterApplied); + Assert.Equal(2, selection.Candidates.Count); + Assert.Empty(selection.Warnings); + } + + [Fact] + public void Exact_Targets_Probe_Only_Selected_Live_Rcbs() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.B01", buffered: true), + Candidate("LD0/LLN0.BR.B02", buffered: true), + Candidate("LD0/LLN0.RP.U01", buffered: false)); + + var selection = MmsRcbAvailabilityTargetSelector.Select( + inventory, + new MmsRcbAvailabilityOptions + { + TargetReportControlReferences = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "LD0/LLN0$BR$B02", + "LD0/LLN0.RP.U01" + } + }); + + Assert.True(selection.TargetFilterApplied); + Assert.Equal(2, selection.RequestedTargetCount); + Assert.Equal(2, selection.MatchedTargetCount); + Assert.Equal( + new[] { "LD0/LLN0.BR.B02", "LD0/LLN0.RP.U01" }, + selection.Candidates.Select(candidate => candidate.Reference).OrderBy(x => x, StringComparer.Ordinal).ToArray()); + } + + [Fact] + public void Missing_Target_Is_Not_Broadened_To_Sibling_Rcb() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", buffered: true), + Candidate("LD0/LLN0.BR.Rpt02", buffered: true)); + + var selection = MmsRcbAvailabilityTargetSelector.Select( + inventory, + new MmsRcbAvailabilityOptions + { + TargetReportControlReferences = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "LD0/LLN0.BR.Rpt03" + } + }); + + Assert.Empty(selection.Candidates); + Assert.Equal(0, selection.MatchedTargetCount); + Assert.Contains( + selection.Warnings, + warning => warning.Contains("not broadened", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Targeted_Mode_Still_Respects_Hard_Maximum() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.B01", buffered: true), + Candidate("LD0/LLN0.BR.B02", buffered: true), + Candidate("LD0/LLN0.BR.B03", buffered: true)); + + var targets = inventory.ReportControls + .Select(candidate => candidate.Reference) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var selection = MmsRcbAvailabilityTargetSelector.Select( + inventory, + new MmsRcbAvailabilityOptions + { + MaxReportControls = 2, + TargetReportControlReferences = targets + }); + + Assert.Equal(3, selection.EligibleCountBeforeLimit); + Assert.Equal(2, selection.Candidates.Count); + Assert.Contains( + selection.Warnings, + warning => warning.Contains("bounded to 2 of 3", StringComparison.OrdinalIgnoreCase)); + } + + private static MmsReportInventory Inventory(params MmsReportControlCandidate[] candidates) + { + var inventory = new MmsReportInventory(); + inventory.ReportControls.AddRange(candidates); + return inventory; + } + + private static MmsReportControlCandidate Candidate(string reference, bool buffered) + { + var slash = reference.IndexOf('/'); + var tail = reference[(slash + 1)..].Split('.'); + return new MmsReportControlCandidate + { + Domain = reference[..slash], + LogicalNode = tail[0], + FunctionalConstraint = tail[1], + Name = tail[2], + Reference = reference, + Buffered = buffered, + DataSetReference = "LD0/LLN0.Events" + }; + } +} From 17220935b30e9d126d30dd9e5e9c401a5c36e7b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:42:27 +0700 Subject: [PATCH 057/176] ci(#144): refresh targeted hot-path read-budget fixes --- src/AR.Iec61850/Mms/MmsRcbAvailability.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs index f0cbc897..aed0a3a3 100644 --- a/src/AR.Iec61850/Mms/MmsRcbAvailability.cs +++ b/src/AR.Iec61850/Mms/MmsRcbAvailability.cs @@ -83,7 +83,7 @@ public sealed class MmsRcbAvailabilityResult public bool TargetFilterApplied { get; init; } public int RequestedTargetReportControlCount { get; init; } public int MatchedTargetReportControlCount { get; init; } - public int RcbStateLogicalReadCount { get; init; } + public int TargetedRcbStateLogicalReadCount { get; init; } public int DataSetDirectoryNetworkReadCount { get; init; } public int DataSetDirectoryCacheHitCount { get; init; } @@ -93,7 +93,8 @@ public sealed class MmsRcbAvailabilityResult public string Summary => $"RCB availability checked: inventory={InventoryReportControlCount}, probed={ReportControls.Count}, " + $"targeted={TargetFilterApplied.ToString().ToLowerInvariant()}, available={AvailableCount}, in-use={InUseCount}, unknown={UnknownCount}, " + - $"rcbStateReads={RcbStateLogicalReadCount}, datasetReads={DataSetDirectoryNetworkReadCount}, datasetCacheHits={DataSetDirectoryCacheHitCount}."; + $"targetedRcbStateReads={(TargetFilterApplied ? TargetedRcbStateLogicalReadCount.ToString() : "n/a")}, " + + $"datasetReads={DataSetDirectoryNetworkReadCount}, datasetCacheHits={DataSetDirectoryCacheHitCount}."; } public static class MmsRcbAvailabilityEvaluator From bad81df5016e4b6a130b00872610118de80e3cf7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:42:31 +0700 Subject: [PATCH 058/176] ci(#144): refresh targeted hot-path read-budget fixes --- src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs index 75f973a3..5a859e87 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs @@ -93,7 +93,7 @@ public async Task CheckReportControlAvailabilityAsync( TargetFilterApplied = selection.TargetFilterApplied, RequestedTargetReportControlCount = selection.RequestedTargetCount, MatchedTargetReportControlCount = selection.MatchedTargetCount, - RcbStateLogicalReadCount = rcbStateLogicalReads, + TargetedRcbStateLogicalReadCount = rcbStateLogicalReads, DataSetDirectoryNetworkReadCount = dataSetDirectoryNetworkReads, DataSetDirectoryCacheHitCount = dataSetDirectoryCacheHits, ReportControls = snapshots From 1a033ab5e4817a86debc48d6bd4fc7ba7ce662c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:43:41 +0700 Subject: [PATCH 059/176] ci(#144): refresh structure type-prewarm optimization --- .../Mms/MmsReportControlSemanticWrite.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs index e7f2002e..653559b3 100644 --- a/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs +++ b/src/AR.Iec61850/Mms/MmsReportControlSemanticWrite.cs @@ -158,6 +158,80 @@ public sealed partial class MmsClientSession private void ResetReportSemanticTypeEvidence() => _reportSemanticTypeEvidence.Clear(); + /// + /// Primes multiple scalar-field type contracts from one complete RCB + /// GetVariableAccessAttributes response when the server exposes named structure + /// components. This is an optimization only: missing/unsupported structure evidence + /// leaves the normal exact-leaf preflight unchanged. + /// + private async Task PrimeReportSemanticTypeEvidenceFromStructureAsync( + MmsReportControlCandidate rcb, + IReadOnlyCollection writes, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(rcb); + ArgumentNullException.ThrowIfNull(writes); + + var pending = writes + .DistinctBy(write => write.Attribute, StringComparer.OrdinalIgnoreCase) + .Where(write => + { + var reference = BuildReportAttributeReference(rcb, write.Attribute); + return !_reportSemanticTypeEvidence.ContainsKey(ReportSemanticTypeCacheKey(reference)); + }) + .ToArray(); + + // A single field gains nothing from a structure probe because its normal leaf + // preflight costs the same one confirmed service request. + if (pending.Length < 2) + return; + + MmsVariableAccessAttributesResult structure; + try + { + var baseReference = MmsObjectReference.Parse(rcb.Reference, rcb.FunctionalConstraint); + structure = await GetVariableAccessAttributesAsync( + baseReference, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return; + } + + if (!structure.IsSuccess || + structure.TypeSpecification is not { MmsType: "structure" } type || + type.Children.Count == 0) + { + return; + } + + foreach (var write in pending) + { + var child = type.Children.FirstOrDefault(component => + string.Equals(component.Name, write.Attribute, StringComparison.OrdinalIgnoreCase)); + if (child is null) + continue; + + var reference = BuildReportAttributeReference(rcb, write.Attribute); + var live = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = reference, + TypeSpecification = child, + Source = "GetVariableAccessAttributes(RCB structure)", + Message = $"RCB structure TypeSpecification supplied exact component {write.Attribute}:{child.MmsType}." + }; + var evidence = MmsReportSemanticTypePolicy.Evaluate( + write, + reference.ToString(), + live); + + if (evidence.IsExact) + _reportSemanticTypeEvidence[ReportSemanticTypeCacheKey(reference)] = evidence; + } + } + /// /// Normal RCB scalar mutation path. The first mutation of each exact field on an /// association performs a bounded exact GetVariableAccessAttributes check. Exact From 7cef117158ec8d70facc11192228598c814c6f9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:43:45 +0700 Subject: [PATCH 060/176] ci(#144): refresh structure type-prewarm optimization --- .../Mms/MmsClientSession.SclStaticReport.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs index f2bfdebd..af26a9cb 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs @@ -123,6 +123,23 @@ public async Task StartConfiguredStaticRe }; } + var normalPathWrites = new List + { + MmsReportControlSemanticWrite.ReportEnable(true) + }; + if (!rcb.Buffered && + rcb.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase)) + { + normalPathWrites.Add(MmsReportControlSemanticWrite.Reservation(true)); + } + if (triggerGeneralInterrogation) + normalPathWrites.Add(MmsReportControlSemanticWrite.GeneralInterrogation(true)); + + await PrimeReportSemanticTypeEvidenceFromStructureAsync( + rcb, + normalPathWrites, + cancellationToken).ConfigureAwait(false); + monitor = new MmsPersistentReportMonitorSession( plan, rcb, From a39e932760e83e9a4dd46ee2e4942fb73485b8c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:47:20 +0700 Subject: [PATCH 061/176] ci(#144): overlay asynchronous armed-to-traffic proof state --- .../Mms/MmsPersistentReportMonitor.cs | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index 6dcb6c0a..5a44096e 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -2,8 +2,19 @@ namespace AR.Iec61850.Mms; +public enum MmsPersistentReportTrafficState +{ + ArmedAwaitingTraffic, + TrafficProven, + Stopped +} + public sealed class MmsPersistentReportMonitorSession { + private long _firstRoutedReportUtcTicks; + private long _lastRoutedReportUtcTicks; + private int _routedReportCount; + internal MmsPersistentReportMonitorSession( MmsReportSubscriptionPlan plan, MmsReportControlCandidate reportControl, @@ -38,10 +49,35 @@ internal MmsPersistentReportMonitorSession( public int ReportCount { get; internal set; } public int PollReadCount { get; internal set; } public bool IsStopped { get; internal set; } + public int RoutedReportCount => Volatile.Read(ref _routedReportCount); + public DateTimeOffset? FirstRoutedReportAt => ToUtcTimestamp(Volatile.Read(ref _firstRoutedReportUtcTicks)); + public DateTimeOffset? LastRoutedReportAt => ToUtcTimestamp(Volatile.Read(ref _lastRoutedReportUtcTicks)); + public MmsPersistentReportTrafficState TrafficState => IsStopped + ? MmsPersistentReportTrafficState.Stopped + : RoutedReportCount > 0 + ? MmsPersistentReportTrafficState.TrafficProven + : MmsPersistentReportTrafficState.ArmedAwaitingTraffic; + public TimeSpan? FirstReportLatency => FirstRoutedReportAt.HasValue + ? FirstRoutedReportAt.Value - StartedAt + : null; + internal ConcurrentQueue PendingReports { get; } = new(); + internal void ObserveRoutedReport(MmsReportFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + var ticks = frame.ReceivedAt.UtcDateTime.Ticks; + Interlocked.CompareExchange(ref _firstRoutedReportUtcTicks, ticks, 0); + Interlocked.Exchange(ref _lastRoutedReportUtcTicks, ticks); + Interlocked.Increment(ref _routedReportCount); + } + public string Summary => - $"persistent report monitor: rcb={ReportControl.Reference}, dataset={Plan.DataSetReference}, mode={Plan.Mode}, reports={ReportCount}, stopped={IsStopped}"; + $"persistent report monitor: rcb={ReportControl.Reference}, dataset={Plan.DataSetReference}, mode={Plan.Mode}, " + + $"traffic={TrafficState}, routedReports={RoutedReportCount}, drainedReports={ReportCount}, stopped={IsStopped}"; + + private static DateTimeOffset? ToUtcTimestamp(long ticks) + => ticks <= 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero); } public sealed class MmsPersistentReportMonitorStartResult @@ -596,6 +632,7 @@ private void RoutePersistentInformationReport(byte[] payload) } var frame = MmsReportFrameMapper.Map(decoded, target.Plan.Members, DateTimeOffset.UtcNow); + target.ObserveRoutedReport(frame); target.PendingReports.Enqueue(frame); LastReceiveRoutingSummary = $"Routed InformationReport to {target.ReportControl.Reference} by {evidence}. RptID={RoutingTextOrDash(header.ReportId)}, DatSet={RoutingTextOrDash(header.DataSetReference)}."; From f5e8a45b6a725cc6bf3c9b8a5406eb8aaf4e6d29 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:50:11 +0700 Subject: [PATCH 062/176] ci(#144): overlay canonical-to-live RCB target resolver --- .../MmsCanonicalStaticRcbTargetResolver.cs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs new file mode 100644 index 00000000..da44d3b8 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs @@ -0,0 +1,129 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +public sealed class MmsCanonicalStaticRcbTargetSegment +{ + public string DataSetReference { get; init; } = string.Empty; + public IReadOnlyList ConfiguredReportControlReferences { get; init; } = Array.Empty(); + public IReadOnlyList ExactLiveReportControlReferences { get; init; } = Array.Empty(); + public string Resolution { get; init; } = string.Empty; +} + +public sealed class MmsCanonicalStaticRcbTargetResolution +{ + public IReadOnlySet ExactLiveReportControlReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + public IReadOnlyList Segments { get; init; } + = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public bool HasTargets => ExactLiveReportControlReferences.Count > 0; + public int CoveredDataSetCount => Segments.Count; +} + +/// +/// Resolves canonical configured-static coverage to concrete live RCB references without +/// 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. +/// +/// If neither evidence path is available 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. +/// +public static class MmsCanonicalStaticRcbTargetResolver +{ + public static MmsCanonicalStaticRcbTargetResolution Resolve( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventory liveInventory) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(liveInventory); + + var allTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + var segments = new List(); + var warnings = new List(); + + foreach (var segment in coverage.Segments) + { + if (segment.SelectedSignalReferences.Length == 0) + continue; + + var canonicalDataSet = Normalize(segment.DataSetReference); + var configuredReferences = segment.ReportControls + .Select(report => Normalize(report.Reference)) + .Where(reference => reference.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var configuredBufferedFamilies = segment.ReportControls + .Select(report => report.Buffered) + .Distinct() + .ToHashSet(); + + var exactReferenceMatches = liveInventory.ReportControls + .Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference))) + .ToArray(); + + var exactDataSetMatches = canonicalDataSet.Length == 0 + ? Array.Empty() + : liveInventory.ReportControls + .Where(candidate => + string.Equals( + Normalize(candidate.DataSetReference), + canonicalDataSet, + StringComparison.OrdinalIgnoreCase)) + .Where(candidate => + configuredBufferedFamilies.Count == 0 || + configuredBufferedFamilies.Contains(candidate.Buffered)) + .ToArray(); + + var matched = exactReferenceMatches + .Concat(exactDataSetMatches) + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Reference)) + .DistinctBy(candidate => Normalize(candidate.Reference), StringComparer.OrdinalIgnoreCase) + .OrderBy(candidate => candidate.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + 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"; + + 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."); + } + + segments.Add(new MmsCanonicalStaticRcbTargetSegment + { + DataSetReference = segment.DataSetReference, + ConfiguredReportControlReferences = configuredReferences + .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase) + .ToArray(), + ExactLiveReportControlReferences = matched + .Select(candidate => candidate.Reference) + .ToArray(), + Resolution = resolution + }); + } + + return new MmsCanonicalStaticRcbTargetResolution + { + ExactLiveReportControlReferences = allTargets, + Segments = segments, + Warnings = warnings + }; + } + + private static string Normalize(string? value) + => MmsRcbAvailabilityEvaluator.NormalizeReference(value); +} From 7f7328ebe3bf1e91f374b8a2795a66b96aa02c0e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:50:14 +0700 Subject: [PATCH 063/176] ci(#144): overlay canonical-to-live RCB target resolver --- ...msCanonicalStaticRcbTargetResolverTests.cs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs new file mode 100644 index 00000000..12bcbef0 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs @@ -0,0 +1,125 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticRcbTargetResolverTests +{ + [Fact] + public void Exact_Live_Dataset_Binding_Targets_Only_Covered_Rcb_Family() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Rpt", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true), + Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", buffered: true), + Candidate("LD0/LLN0.RP.Rpt01", "LD0/LLN0.Events", buffered: false), + Candidate("LD0/LLN0.BR.Other01", "LD0/LLN0.Other", buffered: true)); + + var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + + Assert.True(resolution.HasTargets); + Assert.Equal( + new[] { "LD0/LLN0.BR.Rpt01", "LD0/LLN0.BR.Rpt02" }, + resolution.ExactLiveReportControlReferences.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray()); + Assert.Equal("exact-live-dataset-binding", resolution.Segments.Single().Resolution); + } + + [Fact] + public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_Not_Yet_Populated() + { + 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", string.Empty, buffered: true), + Candidate("LD0/LLN0.BR.Rpt02", string.Empty, buffered: true)); + + 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); + } + + [Fact] + public void Unresolved_Segment_Does_Not_Guess_Name_Family() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Rpt", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", string.Empty, buffered: true), + Candidate("LD0/LLN0.BR.Rpt02", string.Empty, buffered: true)); + + var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + + Assert.False(resolution.HasTargets); + Assert.Empty(resolution.ExactLiveReportControlReferences); + Assert.Equal("unresolved", resolution.Segments.Single().Resolution); + Assert.Contains( + resolution.Warnings, + warning => warning.Contains("No sibling/name-family guess", StringComparison.OrdinalIgnoreCase)); + } + + private static CanonicalStaticReportCoveragePlan Coverage( + string dataSet, + CanonicalReportControl configured) + => new() + { + Segments = new[] + { + new CanonicalStaticReportCoverageSegment + { + DataSetReference = dataSet, + SourceDataSetReference = dataSet, + SelectedSignalReferences = new[] { "LD0/XCBR1.Pos.stVal" }, + OrderedMembers = new[] + { + new CanonicalDataSetMember( + 0, + "LD0/XCBR1.Pos", + "ST", + new CanonicalProvenance(CanonicalEvidenceSource.LiveMms, CanonicalConfidence.Exact)) + }, + ReportControls = new[] { configured } + } + } + }; + + private static MmsReportInventory Inventory(params MmsReportControlCandidate[] controls) + { + var inventory = new MmsReportInventory(); + inventory.ReportControls.AddRange(controls); + return inventory; + } + + private static MmsReportControlCandidate Candidate( + string reference, + string dataSet, + bool buffered) + => new() + { + Reference = reference, + DataSetReference = dataSet, + Buffered = buffered + }; +} From 4c31f11a2b3e54948733ea1cb28eef4511c823a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:51:26 +0700 Subject: [PATCH 064/176] ci(#144): refresh canonical targeted-RCB integration --- .../Canonical/CanonicalLiveModelAdapter.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs index 0d9cb2db..8038c3f1 100644 --- a/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs +++ b/src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs @@ -1,4 +1,5 @@ using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; namespace AR.Iec61850.Engineering.Canonical; @@ -34,6 +35,24 @@ public static CanonicalIedModel FromLiveDiscovery(LiveIedModelDiscoveryDocument }); } + /// + /// Public source-correct canonical ingress for an already opened SCL IED workspace. + /// Applications should use this instead of re-labeling the workspace DesignModel as + /// live discovery merely to reuse canonical planning. + /// + public static CanonicalIedModel FromSclWorkspace(SclIedWorkspace workspace) + { + ArgumentNullException.ThrowIfNull(workspace); + + return FromSclProjection( + workspace.DesignModel, + sourceName: "SCL workspace", + sourceEdition: string.Empty, + iedName: workspace.IedName, + accessPointName: workspace.AccessPointName, + domainAliases: EmptyAliases); + } + internal static CanonicalIedModel FromSclProjection( LiveIedModelDiscoveryDocument projection, string sourceName, From c69bebf5a54da3ebad65cbb786c0216d924bfc3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:51:31 +0700 Subject: [PATCH 065/176] ci(#144): refresh canonical targeted-RCB integration --- .../CanonicalLiveModelAdapterTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs index 34364f1e..15d3b73d 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveModelAdapterTests.cs @@ -1,5 +1,6 @@ using AR.Iec61850.Discovery; using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Scl.Workspace; namespace AR.Iec61850.Tests.Engineering; @@ -71,6 +72,39 @@ public void FromLiveDiscovery_Preserves_Identity_Provenance_And_Compacts_Repeate Assert.Equal("192.0.2.10", model.Communication.Host.Value); } + [Fact] + public void FromSclWorkspace_Preserves_Scl_Ingress_Provenance() + { + var workspace = new SclIedWorkspace + { + IedName = "IED_SCL", + AccessPointName = "P1", + DesignModel = new LiveIedModelDiscoveryDocument + { + Source = "SclDesign", + IedName = "IED_SCL", + AccessPointName = "P1", + DataSets = + [ + new LiveIedDataSetModel + { + Reference = "IED_SCLLD0/LLN0$Events", + Domain = "IED_SCLLD0", + LogicalNode = "LLN0", + Name = "Events" + } + ] + } + }; + + var model = CanonicalLiveModelAdapter.FromSclWorkspace(workspace); + + Assert.Equal(CanonicalIngressKind.SclFile, model.Source.Ingress); + Assert.Equal(CanonicalEvidenceSource.SclDeclared, model.Identity.Provenance.Source); + Assert.Equal("IED_SCL", model.Identity.Name); + Assert.Equal("IED_SCLLD0/LLN0.Events", model.DataSets.Single().Reference); + } + private static LiveIedDataAttributeModel Attribute(string reference, string path) => new() { From 1868ea8f197aa2c9290540e1d07bbe3fe82c6297 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:51:37 +0700 Subject: [PATCH 066/176] ci(#144): refresh canonical targeted-RCB integration From c0609d5c3f5711bff1a604aefc6f514f17fa502c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:51:40 +0700 Subject: [PATCH 067/176] ci(#144): refresh canonical targeted-RCB integration From cba751f74097a32c0a0067e03b7f747b8b464c50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:19:41 +0700 Subject: [PATCH 068/176] perf(reporting): integrate event-driven report wake for exact-head CI (#144) --- src/AR.Iec61850/Mms/MmsReceiveRouter.cs | 43 +++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs index 052909bc..402ad4fc 100644 --- a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs +++ b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs @@ -25,6 +25,7 @@ public sealed class MmsReceiveRouter private readonly Queue _unconfirmed = new(); private readonly Queue _unmatched = new(); private readonly Dictionary> _informationReportSubscriptions = new(); + private readonly SemaphoreSlim _informationReportAvailable = new(0, 1); public int QueuedConfirmedResultCount { @@ -88,6 +89,7 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload) if (envelope.IsInformationReport) { _informationReports.Enqueue(envelope); + SignalInformationReportAvailable(); foreach (var subscription in _informationReportSubscriptions.Values) subscription.Writer.TryWrite(envelope); @@ -152,6 +154,31 @@ public bool TryDequeueInformationReport(out MmsPduEnvelope envelope) return false; } + /// + /// Waits until the canonical InformationReport queue has work without polling. + /// The signal is binary: multiple reports coalesce into one wake because callers + /// drain the queue after waking. Queue contents remain the single routing authority. + /// + internal async Task WaitForInformationReportAsync( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (QueuedInformationReportCount > 0) + { + _informationReportAvailable.Wait(0); + return true; + } + + if (timeout <= TimeSpan.Zero) + return false; + + return await _informationReportAvailable + .WaitAsync(timeout, cancellationToken) + .ConfigureAwait(false); + } + internal MmsInformationReportSubscription SubscribeInformationReports(int capacity = 32) { var boundedCapacity = Math.Clamp(capacity, 1, 1024); @@ -197,6 +224,18 @@ internal void FaultInformationReportSubscriptions(Exception exception) subscription.Writer.TryComplete(exception); } + private void SignalInformationReportAvailable() + { + try + { + _informationReportAvailable.Release(); + } + catch (SemaphoreFullException) + { + // A wake is already pending. The queue remains authoritative and will be drained. + } + } + public void Clear() { Channel[] subscriptions; @@ -210,6 +249,10 @@ public void Clear() _informationReportSubscriptions.Clear(); } + while (_informationReportAvailable.Wait(0)) + { + } + foreach (var subscription in subscriptions) subscription.Writer.TryComplete(new IOException("MMS association receive routing was reset.")); } From 471c1f1e489b4fffdc929c577d19a2579e36c8fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:19:46 +0700 Subject: [PATCH 069/176] perf(reporting): integrate event-driven report wake for exact-head CI (#144) --- .../Mms/MmsPersistentReportMonitor.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index 5a44096e..403ed96f 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -575,9 +575,26 @@ private async Task ReceiveAndDispatchPersistentReportsAsync( if (IsReceivePumpRunning) { - var delay = remaining < TimeSpan.FromMilliseconds(25) ? remaining : TimeSpan.FromMilliseconds(25); - if (delay > TimeSpan.Zero) - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + // The association-owned receive pump already blocks on the transport. + // Do not add a 25 ms application polling loop on top of it. Wait for the + // router's binary InformationReport signal, bounded by the next legitimate + // scheduler deadline (residual polling) or this receive slice deadline. + var wakeAt = deadline; + if (pollDirectory != null && + references.Length > 0 && + nextPollAt < wakeAt) + { + wakeAt = nextPollAt; + } + + var waitFor = wakeAt - DateTimeOffset.UtcNow; + if (waitFor > TimeSpan.Zero) + { + await _receiveRouter.WaitForInformationReportAsync( + waitFor, + cancellationToken).ConfigureAwait(false); + } + continue; } From d5d82c508b65040c2a954dd3b433fad2f6a9a9bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:19:49 +0700 Subject: [PATCH 070/176] perf(reporting): integrate event-driven report wake for exact-head CI (#144) --- .../Mms/MmsReceiveRouterTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs index 31451bd1..377e2144 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs @@ -99,6 +99,54 @@ public async Task Route_FansOutInformationReportWithoutStealingLegacyQueue() Assert.True(queued.IsInformationReport); } + [Fact] + public async Task WaitForInformationReportAsync_ReturnsImmediately_WhenQueueAlreadyHasReport() + { + var router = new MmsReceiveRouter(); + router.Route(BuildInformationReport()); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var available = await router.WaitForInformationReportAsync( + TimeSpan.FromSeconds(1), + timeout.Token); + + Assert.True(available); + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.True(queued.IsInformationReport); + } + + [Fact] + public async Task WaitForInformationReportAsync_WakesFromFutureReport_WithoutPolling() + { + var router = new MmsReceiveRouter(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var wait = router.WaitForInformationReportAsync( + TimeSpan.FromSeconds(1), + timeout.Token); + + await Task.Yield(); + Assert.False(wait.IsCompleted); + + router.Route(BuildInformationReport()); + + Assert.True(await wait); + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.True(queued.IsInformationReport); + } + + [Fact] + public async Task WaitForInformationReportAsync_TimesOut_WhenNoReportArrives() + { + var router = new MmsReceiveRouter(); + + var available = await router.WaitForInformationReportAsync( + TimeSpan.FromMilliseconds(20)); + + Assert.False(available); + Assert.Equal(0, router.QueuedInformationReportCount); + } + [Fact] public async Task Clear_FaultsActiveInformationReportSubscribers() { From 71a8a5252361b77ee0bb3c3d5b4f8046998d9a84 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:20:46 +0700 Subject: [PATCH 071/176] test(reporting): prove burst reports coalesce without stale wake (#144) --- .../Mms/MmsReceiveRouterTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs index 377e2144..4910d871 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs @@ -135,6 +135,21 @@ public async Task WaitForInformationReportAsync_WakesFromFutureReport_WithoutPol Assert.True(queued.IsInformationReport); } + [Fact] + public async Task WaitForInformationReportAsync_CoalescesBurstIntoSingleWake() + { + var router = new MmsReceiveRouter(); + router.Route(BuildInformationReport()); + router.Route(BuildInformationReport()); + + Assert.True(await router.WaitForInformationReportAsync(TimeSpan.FromSeconds(1))); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.True(router.TryDequeueInformationReport(out _)); + + var staleWake = await router.WaitForInformationReportAsync(TimeSpan.FromMilliseconds(20)); + Assert.False(staleWake); + } + [Fact] public async Task WaitForInformationReportAsync_TimesOut_WhenNoReportArrives() { From 9bb2f930125403040923421e1861eec6a2330832 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:35:49 +0700 Subject: [PATCH 072/176] feat(reporting): integrate P6 source-aware acquisition preflight for exact-head CI (#144) --- .../MmsCanonicalStaticAcquisitionPreflight.cs | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs new file mode 100644 index 00000000..a45249d0 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -0,0 +1,264 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +/// +/// Declares where a ReportControl inventory came from. Operational target selection must +/// never mistake an SCL/design projection for live MMS evidence. +/// +public enum MmsReportInventoryAuthority +{ + Unknown, + LiveMmsObserved, + SclDesignProjection +} + +public enum MmsCanonicalStaticAcquisitionProbeStatus +{ + NoSelection, + NoConfiguredStaticCoverage, + LiveInventoryRequired, + ExactTargetsUnresolved, + ExactTargetsReady, + PartialExactTargetsReady, + TargetBudgetExceeded, + AvailabilityChecked, + PartialAvailabilityChecked +} + +public sealed class MmsCanonicalStaticAcquisitionProbePlan +{ + public MmsReportInventoryAuthority InventoryAuthority { get; init; } + public CanonicalStaticReportCoveragePlan Coverage { get; init; } = new(); + public MmsCanonicalStaticRcbTargetResolution TargetResolution { get; init; } = new(); + public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; } + public IReadOnlySet ExactTargetReportControlReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public int RequestedSignalCount => Coverage.RequestedSignalCount; + public int StaticCoveredSignalCount => Coverage.CoveredSignalCount; + public int CoveredSegmentCount => Coverage.Segments.Length; + public int ResolvedSegmentCount => TargetResolution.Segments.Count(segment => + segment.ExactLiveReportControlReferences.Count > 0); + public int UnresolvedSegmentCount => Math.Max(0, CoveredSegmentCount - ResolvedSegmentCount); + public bool HasExactOperationalTargets => + InventoryAuthority == MmsReportInventoryAuthority.LiveMmsObserved && + ExactTargetReportControlReferences.Count > 0; + + public string Summary => + $"canonical static preflight: status={Status}, ingress={Coverage.Ingress}, requested={RequestedSignalCount}, " + + $"staticCovered={StaticCoveredSignalCount}, segments={CoveredSegmentCount}, resolvedSegments={ResolvedSegmentCount}, " + + $"unresolvedSegments={UnresolvedSegmentCount}, exactTargets={ExactTargetReportControlReferences.Count}, inventory={InventoryAuthority}."; +} + +public sealed class MmsCanonicalStaticAcquisitionProbeOptions +{ + public int MaxExactTargets { get; init; } = 64; + public bool ReadDataSetDirectories { get; init; } = true; + public IReadOnlySet CallerOwnedRcbReferences { get; init; } + = new HashSet(StringComparer.OrdinalIgnoreCase); +} + +public sealed class MmsCanonicalStaticAcquisitionProbeResult +{ + public MmsCanonicalStaticAcquisitionProbePlan Plan { get; init; } = new(); + public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; } + public bool NetworkProbePerformed { get; init; } + public MmsRcbAvailabilityResult? Availability { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public string Summary => + $"{Plan.Summary} networkProbe={NetworkProbePerformed.ToString().ToLowerInvariant()}, " + + $"availability={(Availability is null ? "not-run" : Availability.Summary)}"; +} + +/// +/// Pure source-aware coordinator between canonical static coverage and live RCB targeting. +/// +/// It deliberately refuses to turn an SCL-projected inventory into operational live evidence. +/// A caller must provide a live MMS-observed inventory before exact target resolution can +/// authorize network probing. Unresolved segments stay unresolved; this planner never broadens +/// them to siblings or starts a broad RCB scan. +/// +public static class MmsCanonicalStaticAcquisitionPreflightPlanner +{ + public static MmsCanonicalStaticAcquisitionProbePlan Build( + CanonicalIedModel model, + IEnumerable selections, + MmsReportInventory inventory, + MmsReportInventoryAuthority inventoryAuthority) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + ArgumentNullException.ThrowIfNull(inventory); + + var requested = selections.ToArray(); + var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested); + return Build(coverage, inventory, inventoryAuthority); + } + + public static MmsCanonicalStaticAcquisitionProbePlan Build( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventory inventory, + MmsReportInventoryAuthority inventoryAuthority) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(inventory); + + var warnings = new List(); + + if (coverage.RequestedSignalCount == 0) + { + return Result( + coverage, + inventoryAuthority, + new MmsCanonicalStaticRcbTargetResolution(), + MmsCanonicalStaticAcquisitionProbeStatus.NoSelection, + warnings); + } + + if (coverage.CoveredSignalCount == 0 || coverage.Segments.Length == 0) + { + warnings.Add("No selected signal has configured static DataSet/RCB coverage; no operational RCB probe is required."); + return Result( + coverage, + inventoryAuthority, + new MmsCanonicalStaticRcbTargetResolution(), + MmsCanonicalStaticAcquisitionProbeStatus.NoConfiguredStaticCoverage, + warnings); + } + + if (inventoryAuthority != MmsReportInventoryAuthority.LiveMmsObserved) + { + warnings.Add( + "Configured static coverage exists, but operational RCB targeting requires a live MMS-observed report inventory. " + + "SCL/design inventory is structural evidence only and was not promoted to live target evidence."); + return Result( + coverage, + inventoryAuthority, + new MmsCanonicalStaticRcbTargetResolution(), + MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, + warnings); + } + + var targetResolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + warnings.AddRange(targetResolution.Warnings); + + if (!targetResolution.HasTargets) + { + warnings.Add( + "Configured static coverage exists but no exact live RCB target was proven. No broad diagnostic scan or sibling-name guess was started."); + return Result( + coverage, + inventoryAuthority, + targetResolution, + MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsUnresolved, + warnings); + } + + var unresolved = coverage.Segments.Length - targetResolution.Segments.Count(segment => + segment.ExactLiveReportControlReferences.Count > 0); + var status = unresolved > 0 + ? MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady + : MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady; + + return Result(coverage, inventoryAuthority, targetResolution, status, warnings); + } + + private static MmsCanonicalStaticAcquisitionProbePlan Result( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventoryAuthority inventoryAuthority, + MmsCanonicalStaticRcbTargetResolution targetResolution, + MmsCanonicalStaticAcquisitionProbeStatus status, + IReadOnlyList warnings) + => new() + { + InventoryAuthority = inventoryAuthority, + Coverage = coverage, + TargetResolution = targetResolution, + Status = status, + ExactTargetReportControlReferences = targetResolution.ExactLiveReportControlReferences, + Warnings = warnings + .Where(message => !string.IsNullOrWhiteSpace(message)) + .Distinct(StringComparer.Ordinal) + .ToArray() + }; +} + +public sealed partial class MmsClientSession +{ + /// + /// Executes only the exact-target availability work authorized by the pure preflight + /// planner. There is intentionally no implicit broad fallback. + /// + public async Task ProbeCanonicalStaticAcquisitionAsync( + MmsCanonicalStaticAcquisitionProbePlan plan, + MmsReportInventory liveInventory, + MmsIedModelDirectory? directory = null, + MmsCanonicalStaticAcquisitionProbeOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(liveInventory); + options ??= new MmsCanonicalStaticAcquisitionProbeOptions(); + + if (!plan.HasExactOperationalTargets) + { + return new MmsCanonicalStaticAcquisitionProbeResult + { + Plan = plan, + Status = plan.Status, + NetworkProbePerformed = false, + Warnings = plan.Warnings + }; + } + + var maxTargets = Math.Clamp(options.MaxExactTargets, 1, 512); + if (plan.ExactTargetReportControlReferences.Count > maxTargets) + { + return new MmsCanonicalStaticAcquisitionProbeResult + { + Plan = plan, + Status = MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded, + NetworkProbePerformed = false, + Warnings = plan.Warnings + .Concat(new[] + { + $"Exact static RCB target count {plan.ExactTargetReportControlReferences.Count} exceeds the configured hot-path budget {maxTargets}; no partial probe was started." + }) + .ToArray() + }; + } + + EnsureMmsReady(); + + var availability = await CheckReportControlAvailabilityAsync( + liveInventory, + directory, + new MmsRcbAvailabilityOptions + { + MaxReportControls = maxTargets, + ReadDataSetDirectories = options.ReadDataSetDirectories, + TargetReportControlReferences = plan.ExactTargetReportControlReferences, + CallerOwnedRcbReferences = options.CallerOwnedRcbReferences + }, + cancellationToken).ConfigureAwait(false); + + var status = plan.UnresolvedSegmentCount > 0 + ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked + : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked; + + return new MmsCanonicalStaticAcquisitionProbeResult + { + Plan = plan, + Status = status, + NetworkProbePerformed = true, + Availability = availability, + Warnings = plan.Warnings + .Concat(availability.Warnings) + .Distinct(StringComparer.Ordinal) + .ToArray() + }; + } +} From 93e1a8034fb71e1f7a06ecb9428d08d67d1f7e44 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:35:52 +0700 Subject: [PATCH 073/176] feat(reporting): integrate P6 source-aware acquisition preflight for exact-head CI (#144) --- ...anonicalStaticAcquisitionPreflightTests.cs | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs new file mode 100644 index 00000000..15dac8c2 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -0,0 +1,239 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticAcquisitionPreflightTests +{ + [Fact] + public void SclProjectedInventory_IsNeverPromotedToLiveOperationalTargetEvidence() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")); + + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory, + MmsReportInventoryAuthority.SclDesignProjection); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, plan.Status); + Assert.False(plan.HasExactOperationalTargets); + Assert.Empty(plan.ExactTargetReportControlReferences); + Assert.Contains(plan.Warnings, warning => + warning.Contains("structural evidence only", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void LiveInventory_ProducesOnlyExactStaticTargets() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")); + + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true), + Candidate("LD0/LLN0.BR.Other01", "LD0/LLN0.Other", buffered: true)); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory, + MmsReportInventoryAuthority.LiveMmsObserved); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, plan.Status); + Assert.True(plan.HasExactOperationalTargets); + Assert.Equal( + new[] { "LD0/LLN0.BR.Rpt01" }, + plan.ExactTargetReportControlReferences.OrderBy(x => x).ToArray()); + Assert.Equal(1, plan.ResolvedSegmentCount); + Assert.Equal(0, plan.UnresolvedSegmentCount); + } + + [Fact] + public void PartialResolution_RemainsExplicit_AndNeverBroadensUnresolvedSegment() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal"), + Segment( + "LD0/LLN0.Analog", + "LD0/LLN0.RP.Meas", + buffered: false, + "LD0/MMXU1.A.phsA.cVal.mag.f")); + + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true), + Candidate("LD0/LLN0.RP.Meas01", string.Empty, buffered: false), + Candidate("LD0/LLN0.RP.Meas02", string.Empty, buffered: false)); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory, + MmsReportInventoryAuthority.LiveMmsObserved); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady, plan.Status); + Assert.Equal(1, plan.ResolvedSegmentCount); + Assert.Equal(1, plan.UnresolvedSegmentCount); + Assert.Equal(new[] { "LD0/LLN0.BR.Rpt01" }, plan.ExactTargetReportControlReferences); + Assert.DoesNotContain( + plan.ExactTargetReportControlReferences, + reference => reference.Contains("Meas", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task NonOperationalPlan_DoesNotRequireConnectedSession() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")); + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + inventory, + MmsReportInventoryAuthority.SclDesignProjection); + + await using var session = new MmsClientSession(); + var result = await session.ProbeCanonicalStaticAcquisitionAsync(plan, inventory); + + Assert.False(result.NetworkProbePerformed); + Assert.Null(result.Availability); + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, result.Status); + } + + [Fact] + public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe() + { + var targetSet = Enumerable.Range(1, 3) + .Select(index => $"LD0/LLN0.BR.Rpt{index:00}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targetResolution = new MmsCanonicalStaticRcbTargetResolution + { + ExactLiveReportControlReferences = targetSet, + Segments = + [ + new MmsCanonicalStaticRcbTargetSegment + { + DataSetReference = "LD0/LLN0.Events", + ExactLiveReportControlReferences = targetSet.ToArray(), + Resolution = "exact-live-dataset-binding" + } + ] + }; + var plan = new MmsCanonicalStaticAcquisitionProbePlan + { + InventoryAuthority = MmsReportInventoryAuthority.LiveMmsObserved, + Coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")), + TargetResolution = targetResolution, + Status = MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, + ExactTargetReportControlReferences = targetSet + }; + + await using var session = new MmsClientSession(); + var result = await session.ProbeCanonicalStaticAcquisitionAsync( + plan, + Inventory( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", true), + Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", true), + Candidate("LD0/LLN0.BR.Rpt03", "LD0/LLN0.Events", true)), + options: new MmsCanonicalStaticAcquisitionProbeOptions + { + MaxExactTargets = 2 + }); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded, result.Status); + Assert.False(result.NetworkProbePerformed); + Assert.Null(result.Availability); + Assert.Contains(result.Warnings, warning => + warning.Contains("no partial probe", StringComparison.OrdinalIgnoreCase)); + } + + private static CanonicalStaticReportCoveragePlan Coverage( + params CanonicalStaticReportCoverageSegment[] segments) + { + var signals = segments + .SelectMany(segment => segment.SelectedSignalReferences) + .Distinct(StringComparer.Ordinal) + .Select(reference => new CanonicalStaticReportSignalCoverage + { + RequestedReference = reference, + CanonicalReference = reference, + Status = CanonicalStaticReportSignalCoverageStatus.Covered + }) + .ToArray(); + + return new CanonicalStaticReportCoveragePlan + { + Ingress = CanonicalIngressKind.SclFile, + Signals = signals, + Segments = segments + }; + } + + private static CanonicalStaticReportCoverageSegment Segment( + string dataSet, + string rcbReference, + bool buffered, + string selectedSignal) + => new() + { + DataSetReference = dataSet, + SourceDataSetReference = dataSet, + SelectedSignalReferences = [selectedSignal], + OrderedMembers = + [ + new CanonicalDataSetMember( + 0, + selectedSignal[..selectedSignal.LastIndexOf('.')], + selectedSignal.Contains("MMXU", StringComparison.Ordinal) ? "MX" : "ST", + new CanonicalProvenance(CanonicalEvidenceSource.Scl, CanonicalConfidence.Exact)) + ], + ReportControls = + [ + new CanonicalReportControl + { + Reference = rcbReference, + DataSetReference = dataSet, + Buffered = buffered + } + ] + }; + + private static MmsReportInventory Inventory(params MmsReportControlCandidate[] controls) + { + var inventory = new MmsReportInventory(); + inventory.ReportControls.AddRange(controls); + return inventory; + } + + private static MmsReportControlCandidate Candidate( + string reference, + string dataSet, + bool buffered) + => new() + { + Reference = reference, + DataSetReference = dataSet, + Buffered = buffered + }; +} From edf41a8e9124fdc699f5a3b8115cd1b307ee9128 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:38:32 +0700 Subject: [PATCH 074/176] feat(reporting): integrate source-bound P6 preflight for exact-head CI (#144) --- src/AR.Iec61850/Mms/MmsReportInventory.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReportInventory.cs b/src/AR.Iec61850/Mms/MmsReportInventory.cs index 9bc43697..dc6bf756 100644 --- a/src/AR.Iec61850/Mms/MmsReportInventory.cs +++ b/src/AR.Iec61850/Mms/MmsReportInventory.cs @@ -1,5 +1,12 @@ namespace AR.Iec61850.Mms; +public enum MmsReportInventoryAuthority +{ + Unknown, + LiveMmsObserved, + SclDesignProjection +} + public enum MmsRcbDataSetProbeState { NotAttempted, @@ -47,6 +54,11 @@ public sealed class MmsReportControlCandidate public sealed class MmsReportInventory { + /// + /// Provenance of the inventory itself. Operational planners must not infer live + /// authority merely from the presence of plausible RCB names or DataSet bindings. + /// + public MmsReportInventoryAuthority Authority { get; set; } = MmsReportInventoryAuthority.Unknown; public List DataSets { get; } = new(); public List ReportControls { get; } = new(); From 959ca81511b8f5a0ce3c0c3f2efe9ec075b5ced1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:38:36 +0700 Subject: [PATCH 075/176] feat(reporting): integrate source-bound P6 preflight for exact-head CI (#144) --- src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs b/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs index 6c8a9b5b..8f4d9733 100644 --- a/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs +++ b/src/AR.Iec61850/Mms/MmsReportDiscoveryMapper.cs @@ -12,7 +12,10 @@ public static MmsReportInventory BuildInventory(MmsDiscoverySnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); - var inventory = new MmsReportInventory(); + var inventory = new MmsReportInventory + { + Authority = MmsReportInventoryAuthority.LiveMmsObserved + }; inventory.DataSets.AddRange(BuildDataSets(snapshot.DomainVariableLists)); inventory.ReportControls.AddRange(BuildReportControls(snapshot.DomainVariables)); return inventory; From 060a21742f9d325971b8b36c692ed51546c0619a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:38:40 +0700 Subject: [PATCH 076/176] feat(reporting): integrate source-bound P6 preflight for exact-head CI (#144) --- .../MmsCanonicalStaticAcquisitionPreflight.cs | 77 +++++++++++++------ 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs index a45249d0..f1d66004 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -2,17 +2,6 @@ namespace AR.Iec61850.Mms; -/// -/// Declares where a ReportControl inventory came from. Operational target selection must -/// never mistake an SCL/design projection for live MMS evidence. -/// -public enum MmsReportInventoryAuthority -{ - Unknown, - LiveMmsObserved, - SclDesignProjection -} - public enum MmsCanonicalStaticAcquisitionProbeStatus { NoSelection, @@ -86,8 +75,7 @@ public static class MmsCanonicalStaticAcquisitionPreflightPlanner public static MmsCanonicalStaticAcquisitionProbePlan Build( CanonicalIedModel model, IEnumerable selections, - MmsReportInventory inventory, - MmsReportInventoryAuthority inventoryAuthority) + MmsReportInventory inventory) { ArgumentNullException.ThrowIfNull(model); ArgumentNullException.ThrowIfNull(selections); @@ -95,13 +83,12 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( var requested = selections.ToArray(); var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested); - return Build(coverage, inventory, inventoryAuthority); + return Build(coverage, inventory); } public static MmsCanonicalStaticAcquisitionProbePlan Build( CanonicalStaticReportCoveragePlan coverage, - MmsReportInventory inventory, - MmsReportInventoryAuthority inventoryAuthority) + MmsReportInventory inventory) { ArgumentNullException.ThrowIfNull(coverage); ArgumentNullException.ThrowIfNull(inventory); @@ -112,7 +99,7 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( { return Result( coverage, - inventoryAuthority, + inventory.Authority, new MmsCanonicalStaticRcbTargetResolution(), MmsCanonicalStaticAcquisitionProbeStatus.NoSelection, warnings); @@ -123,20 +110,20 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( warnings.Add("No selected signal has configured static DataSet/RCB coverage; no operational RCB probe is required."); return Result( coverage, - inventoryAuthority, + inventory.Authority, new MmsCanonicalStaticRcbTargetResolution(), MmsCanonicalStaticAcquisitionProbeStatus.NoConfiguredStaticCoverage, warnings); } - if (inventoryAuthority != MmsReportInventoryAuthority.LiveMmsObserved) + if (inventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved) { warnings.Add( "Configured static coverage exists, but operational RCB targeting requires a live MMS-observed report inventory. " + "SCL/design inventory is structural evidence only and was not promoted to live target evidence."); return Result( coverage, - inventoryAuthority, + inventory.Authority, new MmsCanonicalStaticRcbTargetResolution(), MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, warnings); @@ -151,7 +138,7 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( "Configured static coverage exists but no exact live RCB target was proven. No broad diagnostic scan or sibling-name guess was started."); return Result( coverage, - inventoryAuthority, + inventory.Authority, targetResolution, MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsUnresolved, warnings); @@ -163,7 +150,7 @@ public static MmsCanonicalStaticAcquisitionProbePlan Build( ? MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady : MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady; - return Result(coverage, inventoryAuthority, targetResolution, status, warnings); + return Result(coverage, inventory.Authority, targetResolution, status, warnings); } private static MmsCanonicalStaticAcquisitionProbePlan Result( @@ -188,6 +175,35 @@ private static MmsCanonicalStaticAcquisitionProbePlan Result( public sealed partial class MmsClientSession { + /// + /// Preferred one-shot runtime entry point. Planning and execution consume the same + /// inventory instance so callers cannot accidentally plan from one evidence source + /// and execute against another. + /// + public Task PrepareCanonicalStaticAcquisitionAsync( + CanonicalIedModel model, + IEnumerable selections, + MmsReportInventory inventory, + MmsIedModelDirectory? directory = null, + MmsCanonicalStaticAcquisitionProbeOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + ArgumentNullException.ThrowIfNull(inventory); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + model, + selections, + inventory); + return ProbeCanonicalStaticAcquisitionAsync( + plan, + inventory, + directory, + options, + cancellationToken); + } + /// /// Executes only the exact-target availability work authorized by the pure preflight /// planner. There is intentionally no implicit broad fallback. @@ -203,6 +219,23 @@ public async Task ProbeCanonicalStatic ArgumentNullException.ThrowIfNull(liveInventory); options ??= new MmsCanonicalStaticAcquisitionProbeOptions(); + if (liveInventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved) + { + return new MmsCanonicalStaticAcquisitionProbeResult + { + Plan = plan, + Status = MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, + NetworkProbePerformed = false, + Warnings = plan.Warnings + .Concat(new[] + { + "The execution inventory is not marked LiveMmsObserved; the exact-target plan was not allowed to touch the network." + }) + .Distinct(StringComparer.Ordinal) + .ToArray() + }; + } + if (!plan.HasExactOperationalTargets) { return new MmsCanonicalStaticAcquisitionProbeResult From 866b10de50d9b7fff124577c507f8b5a94330ea8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:38:44 +0700 Subject: [PATCH 077/176] feat(reporting): integrate source-bound P6 preflight for exact-head CI (#144) --- ...anonicalStaticAcquisitionPreflightTests.cs | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs index 15dac8c2..e3fe05fd 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -16,12 +16,12 @@ public void SclProjectedInventory_IsNeverPromotedToLiveOperationalTargetEvidence "LD0/XCBR1.Pos.stVal")); var inventory = Inventory( + MmsReportInventoryAuthority.SclDesignProjection, Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( coverage, - inventory, - MmsReportInventoryAuthority.SclDesignProjection); + inventory); Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, plan.Status); Assert.False(plan.HasExactOperationalTargets); @@ -41,13 +41,13 @@ public void LiveInventory_ProducesOnlyExactStaticTargets() "LD0/XCBR1.Pos.stVal")); var inventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true), Candidate("LD0/LLN0.BR.Other01", "LD0/LLN0.Other", buffered: true)); var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( coverage, - inventory, - MmsReportInventoryAuthority.LiveMmsObserved); + inventory); Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, plan.Status); Assert.True(plan.HasExactOperationalTargets); @@ -74,14 +74,14 @@ public void PartialResolution_RemainsExplicit_AndNeverBroadensUnresolvedSegment( "LD0/MMXU1.A.phsA.cVal.mag.f")); var inventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true), Candidate("LD0/LLN0.RP.Meas01", string.Empty, buffered: false), Candidate("LD0/LLN0.RP.Meas02", string.Empty, buffered: false)); var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( coverage, - inventory, - MmsReportInventoryAuthority.LiveMmsObserved); + inventory); Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.PartialExactTargetsReady, plan.Status); Assert.Equal(1, plan.ResolvedSegmentCount); @@ -102,11 +102,11 @@ public async Task NonOperationalPlan_DoesNotRequireConnectedSession() buffered: true, "LD0/XCBR1.Pos.stVal")); var inventory = Inventory( + MmsReportInventoryAuthority.SclDesignProjection, Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( coverage, - inventory, - MmsReportInventoryAuthority.SclDesignProjection); + inventory); await using var session = new MmsClientSession(); var result = await session.ProbeCanonicalStaticAcquisitionAsync(plan, inventory); @@ -116,6 +116,36 @@ public async Task NonOperationalPlan_DoesNotRequireConnectedSession() Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, result.Status); } + [Fact] + public async Task ExecutorRejectsInventoryAuthoritySwapBeforeNetworkWork() + { + var coverage = Coverage( + Segment( + "LD0/LLN0.Events", + "LD0/LLN0.BR.Rpt01", + buffered: true, + "LD0/XCBR1.Pos.stVal")); + var liveInventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + liveInventory); + + var structuralOnlyInventory = Inventory( + MmsReportInventoryAuthority.SclDesignProjection, + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", buffered: true)); + + await using var session = new MmsClientSession(); + var result = await session.ProbeCanonicalStaticAcquisitionAsync( + plan, + structuralOnlyInventory); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, result.Status); + Assert.False(result.NetworkProbePerformed); + Assert.Null(result.Availability); + } + [Fact] public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe() { @@ -153,6 +183,7 @@ public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe var result = await session.ProbeCanonicalStaticAcquisitionAsync( plan, Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", true), Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", true), Candidate("LD0/LLN0.BR.Rpt03", "LD0/LLN0.Events", true)), @@ -219,9 +250,14 @@ private static CanonicalStaticReportCoverageSegment Segment( ] }; - private static MmsReportInventory Inventory(params MmsReportControlCandidate[] controls) + private static MmsReportInventory Inventory( + MmsReportInventoryAuthority authority, + params MmsReportControlCandidate[] controls) { - var inventory = new MmsReportInventory(); + var inventory = new MmsReportInventory + { + Authority = authority + }; inventory.ReportControls.AddRange(controls); return inventory; } From 89c655eeb4b3a18ba5317c2dbbf94ed7f948d7e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:39:51 +0700 Subject: [PATCH 078/176] test(reporting): prove live MMS inventory authority is stamped at discovery (#144) --- tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs index 1844f73e..d4ac0166 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportDiscoveryMapperTests.cs @@ -27,6 +27,7 @@ public void BuildInventoryFindsDataSetsAndReportControls() var inventory = MmsReportDiscoveryMapper.BuildInventory(snapshot); + Assert.Equal(MmsReportInventoryAuthority.LiveMmsObserved, inventory.Authority); Assert.Single(inventory.DataSets); Assert.Equal("IED1LD0/LLN0.DataSet", inventory.DataSets[0].Reference); Assert.Equal(2, inventory.ReportControls.Count); From cd7002d2a79331acfb00db812be4f456630c64fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:42:21 +0700 Subject: [PATCH 079/176] feat(reporting): integrate canonical structural inventory projection for exact-head CI (#144) --- src/AR.Iec61850/Mms/MmsReportInventory.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsReportInventory.cs b/src/AR.Iec61850/Mms/MmsReportInventory.cs index dc6bf756..4bab6819 100644 --- a/src/AR.Iec61850/Mms/MmsReportInventory.cs +++ b/src/AR.Iec61850/Mms/MmsReportInventory.cs @@ -4,7 +4,8 @@ public enum MmsReportInventoryAuthority { Unknown, LiveMmsObserved, - SclDesignProjection + SclDesignProjection, + CanonicalModelProjection } public enum MmsRcbDataSetProbeState From b0860f654ee2347a1b3a360a97af92a7cf737158 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:42:25 +0700 Subject: [PATCH 080/176] feat(reporting): integrate canonical structural inventory projection for exact-head CI (#144) --- .../MmsCanonicalReportInventoryProjection.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs b/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs new file mode 100644 index 00000000..d3d31984 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalReportInventoryProjection.cs @@ -0,0 +1,89 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +/// +/// Projects immutable canonical engineering structure into the legacy report-inventory shape +/// without promoting it to live runtime evidence. +/// +/// This exists so applications do not need to duplicate IEC 61850 DataSet/RCB mapping logic. +/// It intentionally does not fabricate indexed RCB instances, writable attribute exposure, +/// reservation state, ownership, or live enable state. +/// +public static class MmsCanonicalReportInventoryProjection +{ + public static MmsReportInventory Build(CanonicalIedModel model) + { + ArgumentNullException.ThrowIfNull(model); + + var inventory = new MmsReportInventory + { + Authority = model.Source.Ingress == CanonicalIngressKind.SclFile + ? MmsReportInventoryAuthority.SclDesignProjection + : MmsReportInventoryAuthority.CanonicalModelProjection + }; + + foreach (var dataSet in model.DataSets) + { + var (domain, tail) = SplitReference(dataSet.Reference); + inventory.DataSets.Add(new MmsDataSetCandidate + { + Domain = FirstNonEmpty(dataSet.MmsDomain, domain), + LogicalNode = FirstNonEmpty(dataSet.LogicalNode, LogicalNodeFromTail(tail)), + Name = FirstNonEmpty(dataSet.Name, LeafFromTail(tail)), + Reference = dataSet.Reference, + RawMmsName = string.Empty + }); + } + + foreach (var report in model.ReportControls) + { + var (domain, tail) = SplitReference(report.Reference); + inventory.ReportControls.Add(new MmsReportControlCandidate + { + Domain = FirstNonEmpty(report.MmsDomain, domain), + LogicalNode = FirstNonEmpty(report.LogicalNode, LogicalNodeFromTail(tail)), + FunctionalConstraint = report.Buffered ? "BR" : "RP", + Name = FirstNonEmpty(report.Name, LeafFromTail(tail)), + Reference = report.Reference, + Buffered = report.Buffered, + DataSetReference = report.DataSetReference, + ReportId = report.ReportId, + ConfRev = report.ConfRev, + IntegrityPeriodMs = report.IntegrityPeriodMs, + BufferTimeMs = report.BufferTimeMs, + TriggerOptions = report.TriggerOptions, + OptionalFields = report.OptionalFields, + Status = "CanonicalModelProjection" + }); + } + + return inventory; + } + + private static (string Domain, string Tail) SplitReference(string? reference) + { + var normalized = (reference ?? string.Empty).Trim(); + var slash = normalized.IndexOf('/'); + return slash > 0 && slash + 1 < normalized.Length + ? (normalized[..slash], normalized[(slash + 1)..]) + : (string.Empty, normalized); + } + + private static string LogicalNodeFromTail(string tail) + { + var dot = tail.IndexOf('.'); + return dot > 0 ? tail[..dot] : string.Empty; + } + + private static string LeafFromTail(string tail) + { + var dot = tail.LastIndexOf('.'); + return dot >= 0 && dot + 1 < tail.Length ? tail[(dot + 1)..] : tail; + } + + private static string FirstNonEmpty(string? primary, string? fallback) + => !string.IsNullOrWhiteSpace(primary) + ? primary.Trim() + : (fallback ?? string.Empty).Trim(); +} From 4d977517e9c05255f95f6386adc48c4add2eaf42 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:42:30 +0700 Subject: [PATCH 081/176] feat(reporting): integrate canonical structural inventory projection for exact-head CI (#144) --- ...CanonicalReportInventoryProjectionTests.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs new file mode 100644 index 00000000..9599f327 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs @@ -0,0 +1,146 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalReportInventoryProjectionTests +{ + [Fact] + public void SclCanonicalProjection_PreservesExactConfiguredIdentity_WithoutInventingLiveEvidence() + { + var model = Model( + CanonicalIngressKind.SclFile, + reportReference: "LD0/LLN0.BR.Rpt", + dataSetReference: "LD0/LLN0.Events"); + + var inventory = MmsCanonicalReportInventoryProjection.Build(model); + + Assert.Equal(MmsReportInventoryAuthority.SclDesignProjection, inventory.Authority); + var report = Assert.Single(inventory.ReportControls); + Assert.Equal("LD0/LLN0.BR.Rpt", report.Reference); + Assert.DoesNotContain("01", report.Reference, StringComparison.Ordinal); + Assert.Equal("LD0/LLN0.Events", report.DataSetReference); + Assert.True(report.Buffered); + Assert.Equal("BR", report.FunctionalConstraint); + Assert.Empty(report.Attributes); + Assert.Equal(string.Empty, report.EnabledState); + Assert.Equal(string.Empty, report.ReservationTimeSeconds); + Assert.Equal(string.Empty, report.Owner); + } + + [Fact] + public void DiscoveryDerivedCanonicalProjection_IsStillStructural_NotLiveRuntimeAuthority() + { + var model = Model( + CanonicalIngressKind.LiveMmsDiscovery, + reportReference: "LD0/LLN0.RP.Rpt01", + dataSetReference: "LD0/LLN0.Analog"); + + var inventory = MmsCanonicalReportInventoryProjection.Build(model); + + Assert.Equal(MmsReportInventoryAuthority.CanonicalModelProjection, inventory.Authority); + var report = Assert.Single(inventory.ReportControls); + Assert.Equal("LD0/LLN0.RP.Rpt01", report.Reference); + Assert.False(report.Buffered); + Assert.Equal("RP", report.FunctionalConstraint); + Assert.Empty(report.Attributes); + } + + [Fact] + public void CanonicalProjection_CannotAuthorizeOperationalStaticTargeting() + { + var model = Model( + CanonicalIngressKind.SclFile, + reportReference: "LD0/LLN0.BR.Rpt01", + dataSetReference: "LD0/LLN0.Events"); + var inventory = MmsCanonicalReportInventoryProjection.Build(model); + + var coverage = new CanonicalStaticReportCoveragePlan + { + Ingress = CanonicalIngressKind.SclFile, + Signals = + [ + new CanonicalStaticReportSignalCoverage + { + RequestedReference = "LD0/XCBR1.Pos.stVal", + CanonicalReference = "LD0/XCBR1.Pos.stVal", + Status = CanonicalStaticReportSignalCoverageStatus.Covered + } + ], + Segments = + [ + new CanonicalStaticReportCoverageSegment + { + DataSetReference = "LD0/LLN0.Events", + SourceDataSetReference = "LD0/LLN0.Events", + SelectedSignalReferences = ["LD0/XCBR1.Pos.stVal"], + ReportControls = model.ReportControls, + OrderedMembers = model.DataSets[0].Members + } + ] + }; + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build(coverage, inventory); + + Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.LiveInventoryRequired, plan.Status); + Assert.False(plan.HasExactOperationalTargets); + } + + private static CanonicalIedModel Model( + CanonicalIngressKind ingress, + string reportReference, + string dataSetReference) + => new() + { + Source = new CanonicalSourceEnvelope + { + Ingress = ingress, + SourceName = ingress == CanonicalIngressKind.SclFile ? "relay.cid" : "LiveMmsDiscovery" + }, + DataSets = + [ + new CanonicalDataSet + { + Reference = dataSetReference, + MmsDomain = "LD0", + LogicalNode = "LLN0", + Name = dataSetReference.Split('.').Last(), + Members = + [ + new CanonicalDataSetMember( + 0, + "LD0/XCBR1.Pos", + "ST", + new CanonicalProvenance( + ingress == CanonicalIngressKind.SclFile + ? CanonicalEvidenceSource.Scl + : CanonicalEvidenceSource.LiveMms, + CanonicalConfidence.Exact)) + ] + } + ], + ReportControls = + [ + new CanonicalReportControl + { + Reference = reportReference, + MmsDomain = "LD0", + LogicalNode = "LLN0", + Name = reportReference.Split('.').Last(), + Buffered = reportReference.Contains(".BR.", StringComparison.Ordinal), + DataSetReference = dataSetReference, + ReportId = "RID", + ConfRev = "1", + TriggerOptions = "dchg,qchg,gi", + OptionalFields = "seqNum,timeStamp,dataSet,reasonCode", + BufferTimeMs = "100", + IntegrityPeriodMs = "0", + Provenance = new CanonicalProvenance( + ingress == CanonicalIngressKind.SclFile + ? CanonicalEvidenceSource.Scl + : CanonicalEvidenceSource.LiveMms, + CanonicalConfidence.Exact) + } + ] + }; +} From b8ff24b2a7d30fa4b8095622b626bc772cefac4c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:43:38 +0700 Subject: [PATCH 082/176] test(reporting): use canonical SCL evidence enum name (#144) --- .../Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs index e3fe05fd..484ebd86 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -237,7 +237,7 @@ private static CanonicalStaticReportCoverageSegment Segment( 0, selectedSignal[..selectedSignal.LastIndexOf('.')], selectedSignal.Contains("MMXU", StringComparison.Ordinal) ? "MX" : "ST", - new CanonicalProvenance(CanonicalEvidenceSource.Scl, CanonicalConfidence.Exact)) + new CanonicalProvenance(CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact)) ], ReportControls = [ From 51fa7092e83472a125aae9a29f033d31f59e6281 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:43:43 +0700 Subject: [PATCH 083/176] test(reporting): use canonical SCL evidence enum name (#144) --- .../Mms/MmsCanonicalReportInventoryProjectionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs index 9599f327..d8863e5e 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalReportInventoryProjectionTests.cs @@ -113,7 +113,7 @@ private static CanonicalIedModel Model( "ST", new CanonicalProvenance( ingress == CanonicalIngressKind.SclFile - ? CanonicalEvidenceSource.Scl + ? CanonicalEvidenceSource.SclDeclared : CanonicalEvidenceSource.LiveMms, CanonicalConfidence.Exact)) ] @@ -137,7 +137,7 @@ private static CanonicalIedModel Model( IntegrityPeriodMs = "0", Provenance = new CanonicalProvenance( ingress == CanonicalIngressKind.SclFile - ? CanonicalEvidenceSource.Scl + ? CanonicalEvidenceSource.SclDeclared : CanonicalEvidenceSource.LiveMms, CanonicalConfidence.Exact) } From 5858a862868a544bf06a0f6cce0478db77b4e51d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:55:01 +0700 Subject: [PATCH 084/176] feat(reporting): integrate P6.1 targeted live RCB reconciliation for exact-head CI (#144) --- .../MmsCanonicalStaticLiveReconciliation.cs | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs new file mode 100644 index 00000000..d2f3b82e --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -0,0 +1,402 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +public enum MmsCanonicalStaticLiveReconciliationStatus +{ + NoStaticCoverage, + Ready, + DomainBudgetExceeded, + CandidateBudgetExceeded, + Partial, + AssociationLost +} + +public sealed class MmsCanonicalStaticLiveDomainTarget +{ + public string Domain { get; init; } = string.Empty; + public bool NeedsBuffered { get; init; } + public bool NeedsUnbuffered { get; init; } +} + +public sealed class MmsCanonicalStaticLiveReconciliationPlan +{ + public MmsCanonicalStaticLiveReconciliationStatus Status { get; init; } + public IReadOnlyList Domains { get; init; } + = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public bool RequiresNetwork => Status == MmsCanonicalStaticLiveReconciliationStatus.Ready && + Domains.Count > 0; +} + +public sealed class MmsCanonicalStaticLiveReconciliationOptions +{ + /// + /// Hard guard. Targeted reconciliation never silently truncates the configured domain set. + /// + public int MaxDomains { get; init; } = 16; + + /// + /// Per-domain GetNameList bound. This is a safety envelope, not a target count. + /// + public int MaxVariableNamesPerDomain { get; init; } = 20000; + + public int MaxNameListPages { get; init; } = 64; + + /// + /// Bounded independent domain chains, additionally capped by negotiated MMS outstandingCalling. + /// + public int MaxConcurrentDomains { get; init; } = 4; + public int UnknownPeerMaxConcurrentDomains { get; init; } = 2; + + /// + /// Maximum exact live RCB candidates that may be read for DatSet binding evidence. + /// The operation fails closed rather than reading an arbitrary subset. + /// + public int MaxReportControlCandidates { get; init; } = 64; +} + +public sealed class MmsCanonicalStaticLiveReconciliationResult +{ + public MmsCanonicalStaticLiveReconciliationPlan Plan { get; init; } = new(); + public MmsCanonicalStaticLiveReconciliationStatus Status { get; init; } + public MmsReportInventory Inventory { get; init; } = new(); + public int EnumeratedVariableCount { get; init; } + public int CandidateReportControlCount { get; init; } + public int DataSetBoundReportControlCount { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + + public bool HasLiveInventory => + Inventory.Authority == MmsReportInventoryAuthority.LiveMmsObserved; + + public string Summary => + $"targeted live RCB reconciliation: status={Status}, domains={Plan.Domains.Count}, " + + $"rawVariables={EnumeratedVariableCount}, candidates={CandidateReportControlCount}, " + + $"datasetBound={DataSetBoundReportControlCount}, authority={Inventory.Authority}."; +} + +public sealed class MmsCanonicalStaticSmartPreparationResult +{ + public CanonicalStaticReportCoveragePlan Coverage { get; init; } = new(); + public MmsCanonicalStaticLiveReconciliationResult Reconciliation { get; init; } = new(); + public MmsCanonicalStaticAcquisitionProbeResult Acquisition { get; init; } = new(); + + public string Summary => + $"{Reconciliation.Summary} {Acquisition.Summary}"; +} + +/// +/// Pure planner for the minimum online structure needed to reconcile configured static +/// reporting. It extracts only MMS domains and BRCB/URCB families actually referenced by +/// selected static coverage. No runtime RCB instance is guessed here. +/// +public static class MmsCanonicalStaticLiveReconciliationPlanner +{ + public static MmsCanonicalStaticLiveReconciliationPlan Build( + CanonicalStaticReportCoveragePlan coverage, + MmsCanonicalStaticLiveReconciliationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(coverage); + options ??= new MmsCanonicalStaticLiveReconciliationOptions(); + + if (coverage.Segments.Length == 0 || coverage.CoveredSignalCount == 0) + { + return new MmsCanonicalStaticLiveReconciliationPlan + { + Status = MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage + }; + } + + var map = new Dictionary(StringComparer.Ordinal); + var warnings = new List(); + + foreach (var report in coverage.Segments.SelectMany(segment => segment.ReportControls)) + { + var domain = ResolveDomain(report.MmsDomain, report.Reference, report.DataSetReference); + if (domain.Length == 0) + { + warnings.Add($"Configured RCB '{report.Reference}' has no resolvable MMS domain and cannot be reconciled online."); + continue; + } + + map.TryGetValue(domain, out var current); + map[domain] = report.Buffered + ? (true, current.Unbuffered) + : (current.Buffered, true); + } + + var domains = map + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new MmsCanonicalStaticLiveDomainTarget + { + Domain = pair.Key, + NeedsBuffered = pair.Value.Buffered, + NeedsUnbuffered = pair.Value.Unbuffered + }) + .ToArray(); + + var maxDomains = Math.Clamp(options.MaxDomains, 1, 256); + if (domains.Length > maxDomains) + { + warnings.Add( + $"Configured static coverage requires {domains.Length} MMS domains, exceeding targeted reconciliation budget {maxDomains}; no partial domain subset is authorized."); + return new MmsCanonicalStaticLiveReconciliationPlan + { + Status = MmsCanonicalStaticLiveReconciliationStatus.DomainBudgetExceeded, + Domains = domains, + Warnings = warnings + }; + } + + return new MmsCanonicalStaticLiveReconciliationPlan + { + Status = domains.Length == 0 + ? MmsCanonicalStaticLiveReconciliationStatus.Partial + : MmsCanonicalStaticLiveReconciliationStatus.Ready, + Domains = domains, + Warnings = warnings + }; + } + + private static string ResolveDomain( + string mmsDomain, + string reportReference, + string dataSetReference) + { + if (!string.IsNullOrWhiteSpace(mmsDomain)) + return mmsDomain.Trim(); + + foreach (var reference in new[] { reportReference, dataSetReference }) + { + var normalized = reference?.Trim() ?? string.Empty; + var slash = normalized.IndexOf('/'); + if (slash > 0) + return normalized[..slash]; + } + + return string.Empty; + } +} + +public sealed partial class MmsClientSession +{ + /// + /// Performs bounded online reconciliation for configured static reporting without + /// running full IED discovery. + /// + /// Work is proportional to the exact MMS domains involved in selected static coverage: + /// one NamedVariable GetNameList chain per domain, followed by whole-RCB/DatSet reads + /// only for live RCB candidates of the required BRCB/URCB class. DataSet directories, + /// FC points, unrelated domains and unrelated RCB classes are not discovered here. + /// + public async Task ReconcileCanonicalStaticReportInventoryAsync( + CanonicalStaticReportCoveragePlan coverage, + MmsCanonicalStaticLiveReconciliationOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(coverage); + options ??= new MmsCanonicalStaticLiveReconciliationOptions(); + + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage, options); + var emptyInventory = new MmsReportInventory + { + Authority = MmsReportInventoryAuthority.LiveMmsObserved + }; + + if (!plan.RequiresNetwork) + { + return new MmsCanonicalStaticLiveReconciliationResult + { + Plan = plan, + Status = plan.Status, + Inventory = emptyInventory, + Warnings = plan.Warnings + }; + } + + EnsureMmsReady(); + + var maxNames = Math.Clamp(options.MaxVariableNamesPerDomain, 1, 100000); + var maxPages = Math.Clamp(options.MaxNameListPages, 1, 256); + var smartOptions = new MmsSmartDiscoveryOptions + { + MaxConcurrentChains = Math.Clamp(options.MaxConcurrentDomains, 1, 64), + UnknownPeerMaxConcurrentChains = Math.Clamp(options.UnknownPeerMaxConcurrentDomains, 1, 32) + }; + var window = ResolveSmartDiscoveryWindow(smartOptions); + + var work = plan.Domains + .Select(target => new SmartNameChainWorkItem( + target.Domain, + MmsGetNameListObjectClass.NamedVariable, + maxNames)) + .ToArray(); + + var chains = await RunSmartNameChainsAsync( + work, + window, + maxPages, + cancellationToken) + .ConfigureAwait(false); + + if (!IsMmsInitiated) + { + return new MmsCanonicalStaticLiveReconciliationResult + { + Plan = plan, + Status = MmsCanonicalStaticLiveReconciliationStatus.AssociationLost, + Inventory = emptyInventory, + EnumeratedVariableCount = chains.Sum(chain => chain.Names.Count), + Warnings = plan.Warnings + .Concat(new[] { "MMS association became unavailable during targeted RCB reconciliation." }) + .ToArray() + }; + } + + var variables = chains.ToDictionary( + chain => chain.Domain, + chain => (IReadOnlyList)chain.Names, + StringComparer.Ordinal); + + var observed = MmsReportDiscoveryMapper.BuildInventory(new MmsDiscoverySnapshot + { + DomainVariables = variables, + 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() + }; + } + + var inventory = new MmsReportInventory + { + Authority = MmsReportInventoryAuthority.LiveMmsObserved + }; + inventory.ReportControls.AddRange(candidates); + + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + + await TryReadReportControlStructureSmartAsync( + candidate, + cancellationToken) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + { + await TryReadReportAttributeSmartAsync( + candidate, + "DatSet", + cancellationToken) + .ConfigureAwait(false); + } + + if (!IsMmsInitiated) + break; + } + + var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete); + var warnings = plan.Warnings + .Concat(chains + .Where(chain => !chain.IsComplete) + .Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}")) + .Concat(candidates + .Where(candidate => string.IsNullOrWhiteSpace(candidate.DataSetReference)) + .Select(candidate => $"Live RCB '{candidate.Reference}' did not expose a usable DatSet binding.")) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return new MmsCanonicalStaticLiveReconciliationResult + { + Plan = plan, + Status = !IsMmsInitiated + ? MmsCanonicalStaticLiveReconciliationStatus.AssociationLost + : complete + ? MmsCanonicalStaticLiveReconciliationStatus.Ready + : MmsCanonicalStaticLiveReconciliationStatus.Partial, + Inventory = inventory, + EnumeratedVariableCount = variables.Values.Sum(list => list.Count), + CandidateReportControlCount = candidates.Length, + DataSetBoundReportControlCount = candidates.Count(candidate => + !string.IsNullOrWhiteSpace(candidate.DataSetReference)), + Warnings = warnings + }; + } + + /// + /// Preferred Open-SCL/Discovery-neutral static acquisition entry point. + /// Canonical coverage is resolved locally first. Only when configured static coverage + /// exists does the engine perform targeted live RCB reconciliation, then exact-target + /// availability. Full discovery is never implied by this method. + /// + public async Task PrepareCanonicalStaticAcquisitionSmartAsync( + CanonicalIedModel model, + IEnumerable selections, + MmsIedModelDirectory? directory = null, + MmsCanonicalStaticLiveReconciliationOptions? reconciliationOptions = null, + MmsCanonicalStaticAcquisitionProbeOptions? acquisitionOptions = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(selections); + + var requested = selections.ToArray(); + var coverage = CanonicalStaticReportCoverageResolver.Resolve(model, requested); + + var reconciliation = await ReconcileCanonicalStaticReportInventoryAsync( + coverage, + reconciliationOptions, + cancellationToken) + .ConfigureAwait(false); + + var plan = MmsCanonicalStaticAcquisitionPreflightPlanner.Build( + coverage, + reconciliation.Inventory); + + var acquisition = await ProbeCanonicalStaticAcquisitionAsync( + plan, + reconciliation.Inventory, + directory, + acquisitionOptions, + cancellationToken) + .ConfigureAwait(false); + + return new MmsCanonicalStaticSmartPreparationResult + { + Coverage = coverage, + Reconciliation = reconciliation, + Acquisition = acquisition + }; + } +} From 9c5292570f55dd81be1b3a6b56f937e5ba1ed276 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:55:05 +0700 Subject: [PATCH 085/176] feat(reporting): integrate P6.1 targeted live RCB reconciliation for exact-head CI (#144) --- ...calStaticLiveReconciliationPlannerTests.cs | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs new file mode 100644 index 00000000..51a4ae5c --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticLiveReconciliationPlannerTests.cs @@ -0,0 +1,179 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticLiveReconciliationPlannerTests +{ + [Fact] + public void Planner_Collapses_Selected_Static_Coverage_To_Minimum_Domain_Set() + { + var coverage = Coverage( + Segment( + "IEDLD0/LLN0.Events", + Report("IEDLD0/LLN0.BR.Rpt", "IEDLD0", buffered: true), + "IEDLD0/XCBR1.Pos.stVal"), + Segment( + "IEDLD0/LLN0.Analog", + Report("IEDLD0/LLN0.RP.Meas", "IEDLD0", buffered: false), + "IEDLD0/MMXU1.A.phsA.cVal.mag.f"), + Segment( + "IEDLD1/LLN0.Events", + Report("IEDLD1/LLN0.BR.Rpt", "IEDLD1", buffered: true), + "IEDLD1/GGIO1.Alm.stVal")); + + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage); + + Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.Ready, plan.Status); + Assert.True(plan.RequiresNetwork); + Assert.Equal(2, plan.Domains.Count); + + var ld0 = plan.Domains.Single(target => target.Domain == "IEDLD0"); + Assert.True(ld0.NeedsBuffered); + Assert.True(ld0.NeedsUnbuffered); + + var ld1 = plan.Domains.Single(target => target.Domain == "IEDLD1"); + Assert.True(ld1.NeedsBuffered); + Assert.False(ld1.NeedsUnbuffered); + } + + [Fact] + public void Planner_Uses_Exact_Reference_Domain_When_MmsDomain_Is_Not_Populated() + { + var coverage = Coverage( + Segment( + "IEDLD0/LLN0.Events", + Report("IEDLD0/LLN0.BR.Rpt", mmsDomain: string.Empty, buffered: true), + "IEDLD0/XCBR1.Pos.stVal")); + + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build(coverage); + + var target = Assert.Single(plan.Domains); + Assert.Equal("IEDLD0", target.Domain); + Assert.True(target.NeedsBuffered); + } + + [Fact] + public void Planner_FailsClosed_When_Domain_Budget_Would_Require_Truncation() + { + var coverage = Coverage( + Segment( + "IEDLD0/LLN0.Events", + Report("IEDLD0/LLN0.BR.Rpt", "IEDLD0", true), + "IEDLD0/XCBR1.Pos.stVal"), + Segment( + "IEDLD1/LLN0.Events", + Report("IEDLD1/LLN0.BR.Rpt", "IEDLD1", true), + "IEDLD1/XCBR1.Pos.stVal")); + + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build( + coverage, + new MmsCanonicalStaticLiveReconciliationOptions + { + MaxDomains = 1 + }); + + Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.DomainBudgetExceeded, plan.Status); + Assert.False(plan.RequiresNetwork); + Assert.Equal(2, plan.Domains.Count); + Assert.Contains(plan.Warnings, warning => + warning.Contains("no partial domain subset", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Planner_DoesNot_Request_Network_When_No_Configured_Static_Coverage_Exists() + { + var plan = MmsCanonicalStaticLiveReconciliationPlanner.Build( + new CanonicalStaticReportCoveragePlan + { + Signals = + [ + new CanonicalStaticReportSignalCoverage + { + RequestedReference = "IEDLD0/MMXU1.A.phsA.cVal.mag.f", + CanonicalReference = "IEDLD0/MMXU1.A.phsA.cVal.mag.f", + Status = CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership + } + ] + }); + + Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage, plan.Status); + Assert.False(plan.RequiresNetwork); + Assert.Empty(plan.Domains); + } + + [Fact] + public async Task Reconcile_NoStaticCoverage_Performs_Zero_Network_Work_On_Disconnected_Session() + { + await using var session = new MmsClientSession(); + + var result = await session.ReconcileCanonicalStaticReportInventoryAsync( + new CanonicalStaticReportCoveragePlan()); + + Assert.Equal(MmsCanonicalStaticLiveReconciliationStatus.NoStaticCoverage, result.Status); + Assert.Equal(MmsReportInventoryAuthority.LiveMmsObserved, result.Inventory.Authority); + Assert.Equal(0, result.EnumeratedVariableCount); + Assert.Equal(0, result.CandidateReportControlCount); + } + + private static CanonicalStaticReportCoveragePlan Coverage( + params CanonicalStaticReportCoverageSegment[] segments) + { + var signals = segments + .SelectMany(segment => segment.SelectedSignalReferences) + .Distinct(StringComparer.Ordinal) + .Select(reference => new CanonicalStaticReportSignalCoverage + { + RequestedReference = reference, + CanonicalReference = reference, + Status = CanonicalStaticReportSignalCoverageStatus.Covered + }) + .ToArray(); + + return new CanonicalStaticReportCoveragePlan + { + Ingress = CanonicalIngressKind.SclFile, + Signals = signals, + Segments = segments + }; + } + + private static CanonicalStaticReportCoverageSegment Segment( + string dataSetReference, + CanonicalReportControl report, + string selectedSignal) + => new() + { + DataSetReference = dataSetReference, + SourceDataSetReference = dataSetReference, + SelectedSignalReferences = [selectedSignal], + ReportControls = [report], + OrderedMembers = + [ + new CanonicalDataSetMember( + 0, + selectedSignal[..selectedSignal.LastIndexOf('.')], + selectedSignal.Contains("MMXU", StringComparison.Ordinal) ? "MX" : "ST", + new CanonicalProvenance(CanonicalEvidenceSource.SclDeclared, CanonicalConfidence.Exact)) + ] + }; + + private static CanonicalReportControl Report( + string reference, + string mmsDomain, + bool buffered) + => new() + { + Reference = reference, + MmsDomain = mmsDomain, + LogicalNode = "LLN0", + Name = reference.Split('.').Last(), + Buffered = buffered, + DataSetReference = reference.Contains("Meas", StringComparison.Ordinal) + ? $"{mmsDomain}/LLN0.Analog" + : $"{mmsDomain}/LLN0.Events", + Provenance = new CanonicalProvenance( + CanonicalEvidenceSource.SclDeclared, + CanonicalConfidence.Exact) + }; +} From 79f9de08b56a7847720d12469a5dadf7d31cc5bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:35:14 +0700 Subject: [PATCH 086/176] fix(scl): resolve same-IED ServerAt chains with fail-closed owner semantics --- .../Scl/SclServerAtModelResolver.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/AR.Iec61850/Scl/SclServerAtModelResolver.cs diff --git a/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs b/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs new file mode 100644 index 00000000..cd1d963c --- /dev/null +++ b/src/AR.Iec61850/Scl/SclServerAtModelResolver.cs @@ -0,0 +1,52 @@ +using System.Xml.Linq; + +namespace AR.Iec61850.Scl; + +/// +/// Resolves an IEC 61850 ServerAt reference within the same IED. This is a +/// model reference, not an alternate MMS address. Fail closed for dangling, +/// ambiguous, or circular references; never bind another IED's server. +/// +internal static class SclServerAtModelResolver +{ + public static XElement? ResolveOwner(XElement ied, XElement selectedAccessPoint) + { + var accessPoints = ied.Elements() + .Where(element => Is(element, "AccessPoint")) + .ToArray(); + var visited = new HashSet(); + + var current = selectedAccessPoint; + while (visited.Add(current)) + { + if (current.Elements().Any(element => Is(element, "Server"))) + return current; + + var references = current.Elements() + .Where(element => Is(element, "ServerAt")) + .ToArray(); + if (references.Length != 1) + return null; + + var referencedName = ((string?)references[0].Attribute("apName"))?.Trim(); + if (string.IsNullOrWhiteSpace(referencedName)) + return null; + + var matches = accessPoints + .Where(element => string.Equals( + ((string?)element.Attribute("name"))?.Trim(), + referencedName, + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matches.Length != 1) + return null; + + current = matches[0]; + } + + return null; // ServerAt cycle + } + + private static bool Is(XElement element, string localName) + => element.Name.LocalName.Equals(localName, StringComparison.Ordinal); +} From 8b57df87ffd151ed6696a4b9bb2400a562307538 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:35:25 +0700 Subject: [PATCH 087/176] fix(scl): preserve referenced Server model for ServerAt endpoint projections --- .../Scl/Workspace/SclWorkspaceService.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs index 1992f3b6..1f602e5a 100644 --- a/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs +++ b/src/AR.Iec61850/Scl/Workspace/SclWorkspaceService.cs @@ -208,6 +208,25 @@ private static LiveIedModelDiscoveryDocument BuildDesignModel( if (!string.IsNullOrWhiteSpace(accessPointName)) { + var selectedAccessPoint = selectedIed.Elements() + .FirstOrDefault(x => Is(x, "AccessPoint") && Same(Attr(x, "name"), accessPointName)); + if (selectedAccessPoint is not null && + !selectedAccessPoint.Elements().Any(x => Is(x, "Server"))) + { + // ServerAt points at another AP's server model, while this AP keeps + // its own Communication/ConnectedAP address. Materialize that model + // ONLY in the isolated projection (never modify the source SCD). + // This allows an AP like Siemens 7SX85/F -> J to retain LD/LN, + // DataSets and RCBs without misrepresenting its MMS endpoint. + var owner = SclServerAtModelResolver.ResolveOwner(selectedIed, selectedAccessPoint); + var server = owner?.Elements().FirstOrDefault(x => Is(x, "Server")); + if (server is not null) + { + selectedAccessPoint.Elements().Where(x => Is(x, "ServerAt")).Remove(); + selectedAccessPoint.AddFirst(new XElement(server)); + } + } + foreach (var otherAccessPoint in selectedIed.Elements() .Where(x => Is(x, "AccessPoint") && !Same(Attr(x, "name"), accessPointName)) .ToArray()) From f111948f7103e15fe0be1e3e0fec84ffc20a7f7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:35:38 +0700 Subject: [PATCH 088/176] fix(scl): recognize valid ServerAt in engineering profile; flag invalid references --- .../SclEngineeringProfileBuilder.cs | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs b/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs index 1798bfe9..30fe9d6e 100644 --- a/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs +++ b/src/AR.Iec61850/Scl/Engineering/SclEngineeringProfileBuilder.cs @@ -47,7 +47,7 @@ public SclEngineeringProfile Build(XDocument xdoc, string sourceName = "") LogicalDeviceCount = logicalDevices.Count(ld => Same(ld.IedName, i.Name)) }).ToList(); - AddStaticFindings(scl, accessPoints, logicalDevices, logicalNodes, externalReferences, findings); + AddStaticFindings(root, scl, accessPoints, logicalDevices, logicalNodes, externalReferences, findings); var capabilities = new SclEngineeringCapabilityMatrix { @@ -104,13 +104,18 @@ private static IEnumerable ParseAccessPoints(XElement foreach (var ap in ied.Elements().Where(e => Is(e, "AccessPoint"))) { var server = ap.Elements().FirstOrDefault(e => Is(e, "Server")); + var owner = server is null + ? SclServerAtModelResolver.ResolveOwner(ied, ap) + : ap; + var effectiveServer = owner?.Elements().FirstOrDefault(e => Is(e, "Server")); yield return new SclEngineeringAccessPoint { IedName = iedName, Name = Attr(ap, "name"), + // HasServer describes a physically declared Server, not ServerAt. HasServer = server is not null, Router = Attr(ap, "router"), - LogicalDeviceCount = server?.Elements().Count(e => Is(e, "LDevice")) ?? 0 + LogicalDeviceCount = effectiveServer?.Elements().Count(e => Is(e, "LDevice")) ?? 0 }; } } @@ -230,6 +235,7 @@ private static HashSet ParseServiceDeclarations(XElement root) } private static void AddStaticFindings( + XElement root, SclDocument document, IReadOnlyList accessPoints, IReadOnlyList logicalDevices, @@ -244,7 +250,24 @@ private static void AddStaticFindings( findings.Add(Finding("High", "SCL_NO_SERVER_MODEL", "No Server/LDevice model was found. The file cannot seed MMS model discovery or simulator model generation.")); foreach (var ap in accessPoints.Where(a => !a.HasServer)) - findings.Add(Finding("Warning", "SCL_ACCESS_POINT_WITHOUT_SERVER", $"AccessPoint {ap.IedName}/{ap.Name} has no Server element.", $"{ap.IedName}/{ap.Name}")); + { + var iedElement = root.Elements().FirstOrDefault(e => + Is(e, "IED") && Same(Attr(e, "name"), ap.IedName)); + var apElement = iedElement?.Elements().FirstOrDefault(e => + Is(e, "AccessPoint") && Same(Attr(e, "name"), ap.Name)); + var hasServerAt = apElement?.Elements().Any(e => Is(e, "ServerAt")) == true; + if (hasServerAt && iedElement is not null && apElement is not null && + SclServerAtModelResolver.ResolveOwner(iedElement, apElement) is not null) + continue; // Valid ServerAt is not a broken/missing Server. + + findings.Add(hasServerAt + ? Finding("High", "SCL_SERVER_AT_UNRESOLVED", + $"AccessPoint {ap.IedName}/{ap.Name} has a dangling, ambiguous or cyclic ServerAt reference.", + $"{ap.IedName}/{ap.Name}") + : Finding("Warning", "SCL_ACCESS_POINT_WITHOUT_SERVER", + $"AccessPoint {ap.IedName}/{ap.Name} has no Server or ServerAt element.", + $"{ap.IedName}/{ap.Name}")); + } foreach (var ln in logicalNodes.Where(l => string.IsNullOrWhiteSpace(l.LnType))) findings.Add(Finding("Warning", "SCL_LN_TYPE_MISSING", $"Logical node {ln.Reference} has no lnType binding.", ln.Reference)); From ebffc41c9da7a1ef32d19da452b21a489ffd9fb0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:36:03 +0700 Subject: [PATCH 089/176] test(scl): Siemens-style J Server plus F ServerAt dual endpoint regression --- .../Scl/StationServerAtRegressionTests.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs diff --git a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs new file mode 100644 index 00000000..8c2a3d4c --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs @@ -0,0 +1,116 @@ +using AR.Iec61850.Scl.Engineering; +using AR.Iec61850.Scl.Workspace; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class StationServerAtRegressionTests +{ + [Fact] + public void Parse_SiemensStyle_7Sx85_ServerAt_Preserves_Model_On_Both_Mms_Endpoints() + { + // Sanitized topology derived from a real Siemens station SCD. + // Do not commit the customer's exported SCD or network configuration. + var document = new SclWorkspaceService().Parse(StationFixture(), "station.scd"); + + Assert.Equal(2, document.MmsEndpoints.Count); + Assert.Equal(2, document.Ieds.Count); + + var j = document.Ieds.Single(ied => ied.AccessPointName == "J"); + var f = document.Ieds.Single(ied => ied.AccessPointName == "F"); + + Assert.Equal("192.0.2.11", j.PreferredEndpoint!.IpAddress); + Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress); + Assert.Equal(102, j.PreferredEndpoint.Port); + Assert.Equal(102, f.PreferredEndpoint.Port); + Assert.False(j.RequiresEndpointBinding); + Assert.False(f.RequiresEndpointBinding); + Assert.True(j.CanBrowseOffline); + Assert.True(f.CanBrowseOffline); + Assert.Single(j.DesignModel.LogicalDevices); + Assert.Single(f.DesignModel.LogicalDevices); + Assert.Equal(j.DesignModel.LogicalDevices[0].MmsDomain, f.DesignModel.LogicalDevices[0].MmsDomain); + Assert.Equal("GR_X_7SX85Application", f.DesignModel.LogicalDevices[0].MmsDomain); + Assert.Equal("F", f.DesignModel.AccessPointName); + Assert.Equal("198.51.100.12", f.DesignModel.Host); + Assert.Single(f.DataSets); + Assert.Single(f.ReportControls); + Assert.DoesNotContain(f.Findings, finding => finding.Code == "SCL_ACCESS_POINT_WITHOUT_SERVER"); + Assert.DoesNotContain(f.Findings, finding => finding.Code == "SCL_SERVER_AT_UNRESOLVED"); + } + + [Fact] + public void Engineering_Profile_Valid_ServerAt_Is_Not_Reported_As_Missing_Server() + { + var profile = new SclEngineeringProfileBuilder().Parse(StationFixture(), "station.scd"); + + Assert.Equal(2, profile.AccessPoints.Count); + var f = profile.AccessPoints.Single(ap => ap.Name == "F"); + Assert.False(f.HasServer); // Not a physical Server: references J via ServerAt. + Assert.Equal(1, f.LogicalDeviceCount); + Assert.DoesNotContain(profile.Findings, item => + item.Code == "SCL_ACCESS_POINT_WITHOUT_SERVER" || + item.Code == "SCL_SERVER_AT_UNRESOLVED"); + } + + [Fact] + public void Dangling_ServerAt_Fails_Closed_Without_Fabricating_Mms_Model_Or_Losing_Ip() + { + var broken = StationFixture().Replace( + "", + "", + StringComparison.Ordinal); + var document = new SclWorkspaceService().Parse(broken, "dangling.scd"); + + var j = document.Ieds.Single(ied => ied.AccessPointName == "J"); + var f = document.Ieds.Single(ied => ied.AccessPointName == "F"); + Assert.True(j.CanBrowseOffline); + Assert.False(f.CanBrowseOffline); + Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress); + Assert.Contains(document.Findings, finding => + finding.Code == "SCL_SERVER_AT_UNRESOLVED" && finding.Severity == "High"); + } + + private static string StationFixture() => """ + +
+ + + +

192.0.2.11

+
+
+ + +

198.51.100.12

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + """; +} From cee8021180db737ae9fee2b7ec6d67adf43a7d03 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:48:51 +0700 Subject: [PATCH 090/176] fix(scl): resolve ServerAt owner for exact MMS domain inventory on selected AP --- src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs index 0643b4b9..e1d35132 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsOnlineContracts.cs @@ -91,10 +91,11 @@ public static SclMmsDomainInventory Read(XDocument document, string iedName, str return Build(canonicalIedName, accessPointName, domains, errors, warnings); } - var server = accessPoint.Elements().FirstOrDefault(element => Is(element, "Server")); + var serverOwner = SclServerAtModelResolver.ResolveOwner(ied, accessPoint); + var server = serverOwner?.Elements().FirstOrDefault(element => Is(element, "Server")); if (server is null) { - errors.Add($"SCL IED '{canonicalIedName}' AccessPoint '{accessPointName}' has no direct Server model."); + errors.Add($"SCL IED '{canonicalIedName}' AccessPoint '{accessPointName}' has no resolvable Server/ServerAt model."); return Build(canonicalIedName, accessPointName, domains, errors, warnings); } From 90ec4f215899bd35a1f26001b9987d04e5a1f157 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:49:02 +0700 Subject: [PATCH 091/176] fix(scl): carry ServerAt model through SCL-assisted initial FC-read projection --- .../Scl/SclInitialFcReadDesignBuilder.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs b/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs index ed5a8f7a..b78c7db4 100644 --- a/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs +++ b/src/AR.Iec61850/Scl/SclInitialFcReadDesignBuilder.cs @@ -63,7 +63,11 @@ public static SclInitialFcReadDesign Read(XDocument document, string iedName, st var selectedAp = selectedIed.Elements() .Where(element => Is(element, "AccessPoint")) .First(element => string.Equals(Attr(element, "name"), accessPointName, StringComparison.Ordinal)); - var server = selectedAp.Elements().First(element => Is(element, "Server")); + // Initial FC-read must use the same canonical ServerAt model authority as + // offline browsing. Keep the selected AP identity for its own MMS address. + var serverOwner = SclServerAtModelResolver.ResolveOwner(selectedIed, selectedAp); + var server = serverOwner?.Elements().FirstOrDefault(element => Is(element, "Server")) + ?? throw new InvalidDataException("Selected AccessPoint has no resolvable Server/ServerAt model."); var domainByInst = new Dictionary(StringComparer.Ordinal); foreach (var lDevice in server.Elements().Where(element => Is(element, "LDevice"))) @@ -107,6 +111,17 @@ public static SclInitialFcReadDesign Read(XDocument document, string iedName, st continue; } + var scopedSelectedAp = otherIed.Elements().First(element => + Is(element, "AccessPoint") && + string.Equals(Attr(element, "name"), accessPointName, StringComparison.Ordinal)); + if (!scopedSelectedAp.Elements().Any(element => Is(element, "Server"))) + { + // Only the disposable projection receives a cloned server. No source + // SCD change, no second server address, no cross-IED inheritance. + scopedSelectedAp.Elements().Where(element => Is(element, "ServerAt")).Remove(); + scopedSelectedAp.AddFirst(new XElement(server)); + } + foreach (var otherAp in otherIed.Elements().Where(element => Is(element, "AccessPoint")).ToArray()) { if (!string.Equals(Attr(otherAp, "name"), accessPointName, StringComparison.Ordinal)) From 0f32a71d92720430a209063a43f02b6033fdc4ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:49:23 +0700 Subject: [PATCH 092/176] test(scl): ServerAt must preserve both MMS domains and SCL-assisted initial FC read --- .../Scl/StationServerAtRegressionTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs index 8c2a3d4c..a4960e55 100644 --- a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs @@ -52,6 +52,30 @@ public void Engineering_Profile_Valid_ServerAt_Is_Not_Reported_As_Missing_Server item.Code == "SCL_SERVER_AT_UNRESOLVED"); } + [Fact] + public void SclAssisted_Preparation_Resolves_F_ServerAt_To_J_Domains_And_Fc_Roots() + { + var xml = StationFixture(); + var domainsJ = SclMmsDomainInventoryReader.Read(xml, "GR_X_7SX85", "J"); + var domainsF = SclMmsDomainInventoryReader.Read(xml, "GR_X_7SX85", "F"); + + Assert.True(domainsJ.IsSuccess); + Assert.True(domainsF.IsSuccess); + Assert.Equal(domainsJ.ExpectedDomains, domainsF.ExpectedDomains); + Assert.Equal("F", domainsF.AccessPointName); + Assert.Single(domainsF.ExpectedDomains); + + var initialJ = SclInitialFcReadDesignBuilder.Read(xml, "GR_X_7SX85", "J"); + var initialF = SclInitialFcReadDesignBuilder.Read(xml, "GR_X_7SX85", "F"); + Assert.True(initialJ.IsSuccess, string.Join(" | ", initialJ.Errors)); + Assert.True(initialF.IsSuccess, string.Join(" | ", initialF.Errors)); + Assert.Equal("F", initialF.Model.AccessPointName); + Assert.Equal(initialJ.Model.LogicalDevices.Count, initialF.Model.LogicalDevices.Count); + Assert.Equal(initialJ.Model.DataSets.Count, initialF.Model.DataSets.Count); + Assert.Equal(initialJ.Model.ReportControls.Count, initialF.Model.ReportControls.Count); + Assert.Single(initialF.Model.LogicalDevices); + } + [Fact] public void Dangling_ServerAt_Fails_Closed_Without_Fabricating_Mms_Model_Or_Losing_Ip() { @@ -68,6 +92,11 @@ public void Dangling_ServerAt_Fails_Closed_Without_Fabricating_Mms_Model_Or_Losi Assert.Equal("198.51.100.12", f.PreferredEndpoint!.IpAddress); Assert.Contains(document.Findings, finding => finding.Code == "SCL_SERVER_AT_UNRESOLVED" && finding.Severity == "High"); + var invalidDomains = SclMmsDomainInventoryReader.Read(broken, "GR_X_7SX85", "F"); + var invalidInitial = SclInitialFcReadDesignBuilder.Read(broken, "GR_X_7SX85", "F"); + Assert.False(invalidDomains.IsSuccess); + Assert.False(invalidInitial.IsSuccess); + Assert.Empty(invalidDomains.ExpectedDomains); } private static string StationFixture() => """ From e45259b710e5e6962203428abbc7fa063a25f52d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:49:35 +0700 Subject: [PATCH 093/176] test(scl): import exact domain and initial-read authority namespace --- tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs index a4960e55..cecab309 100644 --- a/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/StationServerAtRegressionTests.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Scl; using AR.Iec61850.Scl.Engineering; using AR.Iec61850.Scl.Workspace; From 352c81e6a798635c6addcee0683235ca87ad416d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 8 Oct 2026 16:54:09 +0700 Subject: [PATCH 094/176] test(scl): retain no-server fail-closed check with ServerAt-aware diagnostic text --- tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs index 6110c990..5ec16bd8 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsOnlineTests.cs @@ -57,7 +57,7 @@ public void DomainInventoryReader_Fails_Closed_When_Selected_AccessPoint_Has_No_ Assert.False(inventory.IsSuccess); Assert.Empty(inventory.ExpectedDomains); - Assert.Contains(inventory.Errors, error => error.Contains("no direct Server", StringComparison.Ordinal)); + Assert.Contains(inventory.Errors, error => error.Contains("no resolvable Server/ServerAt model", StringComparison.Ordinal)); } [Fact] From fd1e8f4156864ec3ea44b1056079e87071e5773c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 04:55:16 +0700 Subject: [PATCH 095/176] feat(discovery): close static dataset LN schemas without full name enumeration --- .../Discovery/LiveIedVariableTypeHierarchy.cs | 8 +- ...ntSession.SmartVariableAccessAttributes.cs | 24 ++ .../Mms/MmsSmartDiscoveryPolicy.cs | 241 ++++++++++++++++++ 3 files changed, 269 insertions(+), 4 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index a5932071..5b0e27d4 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -84,11 +84,11 @@ result.TypeSpecification is not null && { var domain = result.Reference.Domain.Trim(); var logicalNode = result.Reference.Item.Trim(); - if (!directory.LogicalDevices.TryGetValue(domain, out var logicalDevice) || - !logicalDevice.LogicalNodes.ContainsKey(logicalNode)) - { + // A successful wire-observed exact LN GVA proves the LN even when + // GetNameList stopped before reaching its lexical page. Never create + // an unobserved MMS domain from an arbitrary type result. + if (!directory.LogicalDevices.ContainsKey(domain)) continue; - } foreach (var fcNode in result.TypeSpecification!.Children) { diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs index 316e872a..4383cdfc 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -263,6 +263,30 @@ public async Task> GetVariableA return results; } + + /// + /// Exact static DataSet-member LN type closure. Shares the negotiated invoke + /// window and KPI accounting with smart hierarchy probes. This never reads + /// process values, mutates DataSets, or opens another MMS association. + /// + public async Task> ProbeDataSetMemberTypesSmartAsync( + MmsDiscoveryResult discovery, + IReadOnlyList previousTypes, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentNullException.ThrowIfNull(discovery); + ArgumentNullException.ThrowIfNull(previousTypes); + var roots = MmsSmartTypeProbePolicy.SelectUnprobedDataSetLogicalNodeRoots( + discovery.IedDirectory, discovery.DataSetDirectories, previousTypes); + if (roots.Length == 0) + return Array.Empty(); + return await RunVariableAttributeBatchAsync( + roots, ResolveSmartDiscoveryWindow(options ?? new MmsSmartDiscoveryOptions()), + cancellationToken).ConfigureAwait(false); + } + private void PublishSmartTypeProbeBudget(MmsSmartTypeProbeBudgetSnapshot snapshot) => Volatile.Write(ref _lastSmartTypeProbeBudget, snapshot); diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs index 0e6ddaae..3b9b8dc8 100644 --- a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs @@ -122,6 +122,247 @@ public static MmsObjectReference[] SelectLiveLogicalNodeRoots( .ToArray(); } + + /// + /// Builds bounded type roots exclusively from successful wire-observed DataSet + /// members, even when the fast NamedVariable pagination did not reach that LN. + /// Never probe SCL guesses, repeat a failed/successful LN root, or expand + /// unrelated service metadata on the critical path. + /// + public static MmsObjectReference[] SelectUnprobedDataSetLogicalNodeRoots( + MmsIedModelDirectory directory, + IReadOnlyList directories, + IReadOnlyList attemptedTypes) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(directories); + ArgumentNullException.ThrowIfNull(attemptedTypes); + var domains = directory.LogicalDevices.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var attempted = attemptedTypes + .Where(x => !x.Reference.Item.Contains(' + { + ArgumentNullException.ThrowIfNull(point); + + var parts = Split(point.MmsItemName); + var item = parts.Length >= 3 + ? string.Join('$', parts.Take(3)) + : point.MmsItemName; + return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); + } + + /// + /// Builds exact fallback probes without ever reissuing an exact GVA reference that + /// was already attempted earlier in the same hierarchy ladder. This matters for + /// flat inventories containing LN$FC$DO roots: after a DO-root GVA fails or is + /// shallow, treating that same root as a leaf fallback would otherwise send the + /// identical request twice with no new evidence boundary. + /// + public static MmsObjectReference[] BuildUnprobedExactFallbacks( + IEnumerable unresolvedPoints, + IEnumerable alreadyProbed) + { + ArgumentNullException.ThrowIfNull(unresolvedPoints); + ArgumentNullException.ThrowIfNull(alreadyProbed); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in alreadyProbed) + { + if (string.IsNullOrWhiteSpace(reference.Domain) || string.IsNullOrWhiteSpace(reference.Item)) + continue; + seen.Add(BuildReferenceKey(reference.Domain.Trim(), reference.Item.Trim())); + } + + var fallback = new List(); + foreach (var point in unresolvedPoints + .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && + !string.IsNullOrWhiteSpace(point.MmsItemName)) + .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase)) + { + var reference = point.ToObjectReference(); + var key = BuildReferenceKey(reference.Domain, reference.Item); + if (!seen.Add(key)) + continue; + + fallback.Add(reference); + } + + return fallback.ToArray(); + } + + /// + /// Returns true only when the supplied GVA result can prove the requested MMS + /// item through its TypeSpecification hierarchy. A successful but shallow result + /// is deliberately not treated as coverage for descendants. + /// + public static bool Covers( + MmsVariableAccessAttributesResult result, + string targetMmsItemName) + { + if (!result.IsSuccess || result.TypeSpecification is null || string.IsNullOrWhiteSpace(targetMmsItemName)) + return false; + + var rootParts = Split(result.Reference.Item); + var targetParts = Split(targetMmsItemName); + if (rootParts.Length == 0 || targetParts.Length < rootParts.Length) + return false; + + for (var index = 0; index < rootParts.Length; index++) + { + if (!string.Equals(rootParts[index], targetParts[index], StringComparison.OrdinalIgnoreCase)) + return false; + } + + var current = result.TypeSpecification; + for (var index = rootParts.Length; index < targetParts.Length; index++) + { + var part = targetParts[index]; + var next = current.Children.FirstOrDefault(child => + string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase)); + if (next is null) + return false; + + current = next; + } + + return true; + } + + private static string BuildReferenceKey(string domain, string item) + => string.Concat(domain ?? string.Empty, CompositeKeySeparator, item ?? string.Empty); + + private static string[] Split(string value) + => (value ?? string.Empty).Split( + '$', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} +)) + .Select(x => BuildReferenceKey(x.Reference.Domain, x.Reference.Item)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var roots = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var dataSet in directories.Where(x => x.IsSuccess)) + foreach (var member in dataSet.Members) + { + if (!domains.Contains(member.Domain) || + !(member.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + member.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))) + continue; + + var parts = member.MmsItemName.Split(' + { + ArgumentNullException.ThrowIfNull(point); + + var parts = Split(point.MmsItemName); + var item = parts.Length >= 3 + ? string.Join('$', parts.Take(3)) + : point.MmsItemName; + return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); + } + + /// + /// Builds exact fallback probes without ever reissuing an exact GVA reference that + /// was already attempted earlier in the same hierarchy ladder. This matters for + /// flat inventories containing LN$FC$DO roots: after a DO-root GVA fails or is + /// shallow, treating that same root as a leaf fallback would otherwise send the + /// identical request twice with no new evidence boundary. + /// + public static MmsObjectReference[] BuildUnprobedExactFallbacks( + IEnumerable unresolvedPoints, + IEnumerable alreadyProbed) + { + ArgumentNullException.ThrowIfNull(unresolvedPoints); + ArgumentNullException.ThrowIfNull(alreadyProbed); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in alreadyProbed) + { + if (string.IsNullOrWhiteSpace(reference.Domain) || string.IsNullOrWhiteSpace(reference.Item)) + continue; + seen.Add(BuildReferenceKey(reference.Domain.Trim(), reference.Item.Trim())); + } + + var fallback = new List(); + foreach (var point in unresolvedPoints + .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && + !string.IsNullOrWhiteSpace(point.MmsItemName)) + .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase)) + { + var reference = point.ToObjectReference(); + var key = BuildReferenceKey(reference.Domain, reference.Item); + if (!seen.Add(key)) + continue; + + fallback.Add(reference); + } + + return fallback.ToArray(); + } + + /// + /// Returns true only when the supplied GVA result can prove the requested MMS + /// item through its TypeSpecification hierarchy. A successful but shallow result + /// is deliberately not treated as coverage for descendants. + /// + public static bool Covers( + MmsVariableAccessAttributesResult result, + string targetMmsItemName) + { + if (!result.IsSuccess || result.TypeSpecification is null || string.IsNullOrWhiteSpace(targetMmsItemName)) + return false; + + var rootParts = Split(result.Reference.Item); + var targetParts = Split(targetMmsItemName); + if (rootParts.Length == 0 || targetParts.Length < rootParts.Length) + return false; + + for (var index = 0; index < rootParts.Length; index++) + { + if (!string.Equals(rootParts[index], targetParts[index], StringComparison.OrdinalIgnoreCase)) + return false; + } + + var current = result.TypeSpecification; + for (var index = rootParts.Length; index < targetParts.Length; index++) + { + var part = targetParts[index]; + var next = current.Children.FirstOrDefault(child => + string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase)); + if (next is null) + return false; + + current = next; + } + + return true; + } + + private static string BuildReferenceKey(string domain, string item) + => string.Concat(domain ?? string.Empty, CompositeKeySeparator, item ?? string.Empty); + + private static string[] Split(string value) + => (value ?? string.Empty).Split( + '$', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} +); + if (parts.Length < 3 || + !parts[1].Equals(member.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)) + continue; + + var ln = parts[0].Trim(); + if (ln.Length == 0 || ln.Length > 64 || + !ln.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_')) + continue; + + var key = BuildReferenceKey(member.Domain, ln); + if (!attempted.Contains(key)) + roots.TryAdd(key, new MmsObjectReference(member.Domain, ln, string.Empty)); + } + return roots.Values.OrderBy(x => x.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Item, StringComparer.OrdinalIgnoreCase).ToArray(); + } + public static MmsObjectReference BuildDataObjectRoot(MmsFcResolvedPoint point) { ArgumentNullException.ThrowIfNull(point); From f42318c70a2af8f467d0eb21f0709d6115f6c35a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 04:56:36 +0700 Subject: [PATCH 096/176] test(discovery): gate new LN roots on exact dataset plus successful GVA evidence --- .../Discovery/LiveIedVariableTypeHierarchy.cs | 11 +++-- ...ntSession.SmartVariableAccessAttributes.cs | 31 +++++++++++- .../Mms/MmsSmartDiscoveryPolicyTests.cs | 48 +++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index 5b0e27d4..e9291348 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -84,11 +84,14 @@ result.TypeSpecification is not null && { var domain = result.Reference.Domain.Trim(); var logicalNode = result.Reference.Item.Trim(); - // A successful wire-observed exact LN GVA proves the LN even when - // GetNameList stopped before reaching its lexical page. Never create - // an unobserved MMS domain from an arbitrary type result. - if (!directory.LogicalDevices.ContainsKey(domain)) + // Fail closed: only known directory LN roots are materialized. + // DataSet-root closure first promotes confirmed MMS member references + // into this directory, after a successful exact LN type probe. + if (!directory.LogicalDevices.TryGetValue(domain, out var logicalDevice) || + !logicalDevice.LogicalNodes.ContainsKey(logicalNode)) + { continue; + } foreach (var fcNode in result.TypeSpecification!.Children) { diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs index 4383cdfc..3d189259 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -282,9 +282,38 @@ public async Task> ProbeDataSet discovery.IedDirectory, discovery.DataSetDirectories, previousTypes); if (roots.Length == 0) return Array.Empty(); - return await RunVariableAttributeBatchAsync( + var results = await RunVariableAttributeBatchAsync( roots, ResolveSmartDiscoveryWindow(options ?? new MmsSmartDiscoveryOptions()), cancellationToken).ConfigureAwait(false); + + // Authorize previously unseen LN roots only when two independent live MMS + // observations agree: an exact configured DataSet FCDA/FCD member and a + // successful typed GetVariableAccessAttributes response for that LN. + // Never materialize a foreign LN from an unsolicited GVA result. + var successfulRoots = results + .Where(result => result.IsSuccess && result.TypeSpecification is not null) + .Select(result => result.Reference.Domain + "/" + result.Reference.Item) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var membershipSeeds = discovery.DataSetDirectories + .Where(dataSet => dataSet.IsSuccess) + .SelectMany(dataSet => dataSet.Members) + .Where(member => successfulRoots.Contains(member.Domain + "/" + member.LogicalNode) && + !string.IsNullOrWhiteSpace(member.DataObjectPath) && + (member.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + member.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))) + .Select(member => new MmsFcResolvedPoint + { + Domain = member.Domain, + LogicalNode = member.LogicalNode, + FunctionalConstraint = member.FunctionalConstraint, + DataObjectPath = member.DataObjectPath, + MmsItemName = member.MmsItemName, + Source = "GetNamedVariableListAttributes+GetVariableAccessAttributes", + Confidence = 100 + }) + .ToArray(); + discovery.IedDirectory.AddSupplementalPoints(membershipSeeds); + return results; } private void PublishSmartTypeProbeBudget(MmsSmartTypeProbeBudgetSnapshot snapshot) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs index f69ed5f8..c12259a0 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs @@ -192,6 +192,54 @@ public void DataObjectFallbackRoot_StopsAtLnFcDo() Assert.Equal("MX", root.FunctionalConstraint); } + + [Fact] + public void DataSetClosure_SelectsOnlyObservedMissingStMxRoots_NoDuplicateOrForeignLn() + { + var directory = BuildDirectory(); + var dataset = new MmsDataSetDirectoryResult + { + IsSuccess = true, + Members = + [ + Member("LD0", "eveGGIO1", "ST", "Ind11"), + Member("LD0", "eveGGIO1", "ST", "Ind13"), + Member("LD0", "CSWI1", "ST", "Pos"), + Member("LD0", "CSWI16", "ST", "Pos"), + Member("LD0", "MMXU1", "MX", "A"), + Member("LD0", "FOREIGN1", "CO", "Oper"), + Member("FAKE", "eveGGIO2", "ST", "Ind1") + ] + }; + var already = new[] + { + new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "MMXU1", "") + }, + new MmsVariableAccessAttributesResult + { + IsSuccess = false, + Reference = new MmsObjectReference("LD0", "CSWI1", "") + } + }; + var selected = MmsSmartTypeProbePolicy.SelectUnprobedDataSetLogicalNodeRoots( + directory, [dataset], already); + Assert.Equal(2, selected.Length); + Assert.Equal(["CSWI16", "eveGGIO1"], selected.Select(x => x.Item).ToArray()); + } + + private static MmsDataSetDirectoryMember Member( + string domain, string ln, string fc, string dataObject) => new() + { + Domain = domain, + LogicalNode = ln, + FunctionalConstraint = fc, + DataObjectPath = dataObject, + MmsItemName = ln + "$" + fc + "$" + dataObject, + UserReference = domain + "/" + ln + "." + dataObject + }; private static MmsIedModelDirectory BuildDirectory() { var snapshot = new MmsDiscoverySnapshot From 8b0c0f7e695b016834e40a6f068994dcc945cb1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 10 Oct 2026 05:01:34 +0700 Subject: [PATCH 097/176] fix(discovery): restore complete policy source and deterministic member-root planner --- .../Mms/MmsSmartDiscoveryPolicy.cs | 220 ++---------------- 1 file changed, 14 insertions(+), 206 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs index 3b9b8dc8..31fc6f54 100644 --- a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs @@ -124,10 +124,8 @@ public static MmsObjectReference[] SelectLiveLogicalNodeRoots( /// - /// Builds bounded type roots exclusively from successful wire-observed DataSet - /// members, even when the fast NamedVariable pagination did not reach that LN. - /// Never probe SCL guesses, repeat a failed/successful LN root, or expand - /// unrelated service metadata on the critical path. + /// Wire-authoritative ST/MX LN candidates from successful DataSet directories. + /// No SCL guesses, no duplicate GVA requests, no extra name-list crawl. /// public static MmsObjectReference[] SelectUnprobedDataSetLogicalNodeRoots( MmsIedModelDirectory directory, @@ -137,110 +135,14 @@ public static MmsObjectReference[] SelectUnprobedDataSetLogicalNodeRoots( ArgumentNullException.ThrowIfNull(directory); ArgumentNullException.ThrowIfNull(directories); ArgumentNullException.ThrowIfNull(attemptedTypes); + var domains = directory.LogicalDevices.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); var attempted = attemptedTypes - .Where(x => !x.Reference.Item.Contains(' - { - ArgumentNullException.ThrowIfNull(point); - - var parts = Split(point.MmsItemName); - var item = parts.Length >= 3 - ? string.Join('$', parts.Take(3)) - : point.MmsItemName; - return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); - } - - /// - /// Builds exact fallback probes without ever reissuing an exact GVA reference that - /// was already attempted earlier in the same hierarchy ladder. This matters for - /// flat inventories containing LN$FC$DO roots: after a DO-root GVA fails or is - /// shallow, treating that same root as a leaf fallback would otherwise send the - /// identical request twice with no new evidence boundary. - /// - public static MmsObjectReference[] BuildUnprobedExactFallbacks( - IEnumerable unresolvedPoints, - IEnumerable alreadyProbed) - { - ArgumentNullException.ThrowIfNull(unresolvedPoints); - ArgumentNullException.ThrowIfNull(alreadyProbed); - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var reference in alreadyProbed) - { - if (string.IsNullOrWhiteSpace(reference.Domain) || string.IsNullOrWhiteSpace(reference.Item)) - continue; - seen.Add(BuildReferenceKey(reference.Domain.Trim(), reference.Item.Trim())); - } - - var fallback = new List(); - foreach (var point in unresolvedPoints - .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && - !string.IsNullOrWhiteSpace(point.MmsItemName)) - .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase)) - { - var reference = point.ToObjectReference(); - var key = BuildReferenceKey(reference.Domain, reference.Item); - if (!seen.Add(key)) - continue; - - fallback.Add(reference); - } - - return fallback.ToArray(); - } - - /// - /// Returns true only when the supplied GVA result can prove the requested MMS - /// item through its TypeSpecification hierarchy. A successful but shallow result - /// is deliberately not treated as coverage for descendants. - /// - public static bool Covers( - MmsVariableAccessAttributesResult result, - string targetMmsItemName) - { - if (!result.IsSuccess || result.TypeSpecification is null || string.IsNullOrWhiteSpace(targetMmsItemName)) - return false; - - var rootParts = Split(result.Reference.Item); - var targetParts = Split(targetMmsItemName); - if (rootParts.Length == 0 || targetParts.Length < rootParts.Length) - return false; - - for (var index = 0; index < rootParts.Length; index++) - { - if (!string.Equals(rootParts[index], targetParts[index], StringComparison.OrdinalIgnoreCase)) - return false; - } - - var current = result.TypeSpecification; - for (var index = rootParts.Length; index < targetParts.Length; index++) - { - var part = targetParts[index]; - var next = current.Children.FirstOrDefault(child => - string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase)); - if (next is null) - return false; - - current = next; - } - - return true; - } - - private static string BuildReferenceKey(string domain, string item) - => string.Concat(domain ?? string.Empty, CompositeKeySeparator, item ?? string.Empty); - - private static string[] Split(string value) - => (value ?? string.Empty).Split( - '$', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -} -)) - .Select(x => BuildReferenceKey(x.Reference.Domain, x.Reference.Item)) + .Where(result => !result.Reference.Item.Contains('$')) + .Select(result => BuildReferenceKey(result.Reference.Domain, result.Reference.Item)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var roots = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var dataSet in directories.Where(x => x.IsSuccess)) + var targets = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var dataSet in directories.Where(dataSet => dataSet.IsSuccess)) foreach (var member in dataSet.Members) { if (!domains.Contains(member.Domain) || @@ -248,104 +150,7 @@ private static string[] Split(string value) member.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase))) continue; - var parts = member.MmsItemName.Split(' - { - ArgumentNullException.ThrowIfNull(point); - - var parts = Split(point.MmsItemName); - var item = parts.Length >= 3 - ? string.Join('$', parts.Take(3)) - : point.MmsItemName; - return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); - } - - /// - /// Builds exact fallback probes without ever reissuing an exact GVA reference that - /// was already attempted earlier in the same hierarchy ladder. This matters for - /// flat inventories containing LN$FC$DO roots: after a DO-root GVA fails or is - /// shallow, treating that same root as a leaf fallback would otherwise send the - /// identical request twice with no new evidence boundary. - /// - public static MmsObjectReference[] BuildUnprobedExactFallbacks( - IEnumerable unresolvedPoints, - IEnumerable alreadyProbed) - { - ArgumentNullException.ThrowIfNull(unresolvedPoints); - ArgumentNullException.ThrowIfNull(alreadyProbed); - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var reference in alreadyProbed) - { - if (string.IsNullOrWhiteSpace(reference.Domain) || string.IsNullOrWhiteSpace(reference.Item)) - continue; - seen.Add(BuildReferenceKey(reference.Domain.Trim(), reference.Item.Trim())); - } - - var fallback = new List(); - foreach (var point in unresolvedPoints - .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && - !string.IsNullOrWhiteSpace(point.MmsItemName)) - .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase)) - { - var reference = point.ToObjectReference(); - var key = BuildReferenceKey(reference.Domain, reference.Item); - if (!seen.Add(key)) - continue; - - fallback.Add(reference); - } - - return fallback.ToArray(); - } - - /// - /// Returns true only when the supplied GVA result can prove the requested MMS - /// item through its TypeSpecification hierarchy. A successful but shallow result - /// is deliberately not treated as coverage for descendants. - /// - public static bool Covers( - MmsVariableAccessAttributesResult result, - string targetMmsItemName) - { - if (!result.IsSuccess || result.TypeSpecification is null || string.IsNullOrWhiteSpace(targetMmsItemName)) - return false; - - var rootParts = Split(result.Reference.Item); - var targetParts = Split(targetMmsItemName); - if (rootParts.Length == 0 || targetParts.Length < rootParts.Length) - return false; - - for (var index = 0; index < rootParts.Length; index++) - { - if (!string.Equals(rootParts[index], targetParts[index], StringComparison.OrdinalIgnoreCase)) - return false; - } - - var current = result.TypeSpecification; - for (var index = rootParts.Length; index < targetParts.Length; index++) - { - var part = targetParts[index]; - var next = current.Children.FirstOrDefault(child => - string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase)); - if (next is null) - return false; - - current = next; - } - - return true; - } - - private static string BuildReferenceKey(string domain, string item) - => string.Concat(domain ?? string.Empty, CompositeKeySeparator, item ?? string.Empty); - - private static string[] Split(string value) - => (value ?? string.Empty).Split( - '$', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -} -); + var parts = member.MmsItemName.Split('$'); if (parts.Length < 3 || !parts[1].Equals(member.FunctionalConstraint, StringComparison.OrdinalIgnoreCase)) continue; @@ -357,10 +162,13 @@ private static string[] Split(string value) var key = BuildReferenceKey(member.Domain, ln); if (!attempted.Contains(key)) - roots.TryAdd(key, new MmsObjectReference(member.Domain, ln, string.Empty)); + targets.TryAdd(key, new MmsObjectReference(member.Domain, ln, string.Empty)); } - return roots.Values.OrderBy(x => x.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.Item, StringComparer.OrdinalIgnoreCase).ToArray(); + + return targets.Values + .OrderBy(root => root.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(root => root.Item, StringComparer.OrdinalIgnoreCase) + .ToArray(); } public static MmsObjectReference BuildDataObjectRoot(MmsFcResolvedPoint point) From d8dc2d267d17a7ebf186a4d08fcf88e908c0dc3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 06:55:50 +0700 Subject: [PATCH 098/176] feat(identity): exact trusted-SCL IED/LD mapping on pinned smart-discovery baseline --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 112 ++++++++++++ .../Export/AuthoritativeLiveIedSclExporter.cs | 83 ++++++++- .../Scl/Export/LiveIedSclExportModels.cs | 7 + .../TrustedSclIedIdentityMatcherTests.cs | 172 ++++++++++++++++++ 4 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs create mode 100644 tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs new file mode 100644 index 00000000..a3b8918a --- /dev/null +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Xml.Linq; + +namespace AR.Iec61850.Discovery; + +/// +/// Evidence-only IEC 61850 identity reconciliation. A server exposes MMS domains, +/// not a guaranteed physical IED name or LDevice.inst boundary. Only a unique, +/// fully matched engineering SCL topology can promote a provisional identity. +/// No vendor-name heuristic, network call, cached-IP assumption, or model mutation. +/// +public static class TrustedSclIedIdentityMatcher +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveIedIdentity? TryMatch( + XDocument trustedScl, + IEnumerable observedMmsDomains, + string? connectedHost, + string? selectedIedName = null) + { + ArgumentNullException.ThrowIfNull(trustedScl); + ArgumentNullException.ThrowIfNull(observedMmsDomains); + if (trustedScl.Root?.Name != Scl + "SCL") return null; + var observed = observedMmsDomains + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (observed.Count == 0) return null; + + var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); + foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) + { + var name = ((string?)ied.Attribute("name") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name) || + (!string.IsNullOrWhiteSpace(selectedIedName) && + !name.Equals(selectedIedName.Trim(), StringComparison.OrdinalIgnoreCase))) + continue; + + foreach (var ap in ied.Elements(Scl + "AccessPoint")) + { + var apName = ((string?)ap.Attribute("name") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(apName)) continue; + var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray() + ?? Array.Empty(); + if (devices.Length != observed.Count) continue; + + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var invalid = false; + foreach (var ld in devices) + { + var inst = ((string?)ld.Attribute("inst") ?? "").Trim(); + var ldName = ((string?)ld.Attribute("ldName") ?? "").Trim(); + if (string.IsNullOrWhiteSpace(inst) || !IsValidSclIdentifier(inst)) + { + invalid = true; + break; + } + var domain = ldName.Length > 0 ? ldName : name + inst; + if (!observed.Contains(domain) || !aliases.TryAdd(domain,inst)) + { + invalid = true; + break; + } + } + if (invalid || aliases.Count != observed.Count) continue; + + var connected = trustedScl.Root.Element(Scl + "Communication")? + .Descendants(Scl + "ConnectedAP") + .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; + 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; + 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 + } + matches.Add((name, apName, aliases, ip)); + } + } + // 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; + var match=matches[0]; + return new LiveIedIdentity + { + IedName=match.Ied, + Source="TrustedSclExactDomainMatch", + Confidence=LiveIedDiscoveryConfidenceLevel.High, + IsAmbiguous=false, + CandidateNames=[match.Ied], + LogicalDeviceAliases=match.Aliases, + Evidence=[ + $"Trusted SCL IED '{match.Ied}' / AccessPoint '{match.Ap}' matches all {observed.Count} observed MMS domains and unique LDevice instances.", + string.IsNullOrWhiteSpace(match.Ip) ? "No endpoint IP asserted by SCL." : $"SCL endpoint IP {match.Ip}." + ] + }; + } + + private static bool IsValidSclIdentifier(string s) + => s.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.'); +} diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index ccd70779..b92de8a7 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -26,7 +26,14 @@ public static LiveIedSclExportResult WriteFiles( try { var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); - if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) + if (options.VerifiedIdentity is { } verified) + { + if (!string.IsNullOrWhiteSpace(options.IedNameOverride) && + !verified.IedName.Equals(options.IedNameOverride.Trim(), StringComparison.Ordinal)) + throw new InvalidDataException("Caller IED override conflicts with trusted SCL evidence."); + document = ApplyVerifiedIdentity(document, model, verified); + } + else if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) document = ApplyIdentity(document, model, options.IedNameOverride); document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); @@ -99,6 +106,80 @@ public static XDocument ApplyIdentity( return document; } + + /// + /// Normalizes both IED identity and LD instance mappings from verified SCL + /// topology. Physical MMS domain / FCDA evidence remains unchanged. + /// + public static XDocument ApplyVerifiedIdentity( + XDocument generated, + LiveIedModelDiscoveryDocument model, + LiveIedIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + var observed = model.LogicalDevices + .Select(LogicalDeviceDomain) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!identity.Source.Equals("TrustedSclExactDomainMatch", StringComparison.Ordinal) || + identity.Confidence != LiveIedDiscoveryConfidenceLevel.High || + identity.IsAmbiguous || + observed.Count == 0 || + identity.LogicalDeviceAliases.Count != observed.Count || + identity.LogicalDeviceAliases.Keys.Any(x => !observed.Contains(x)) || + identity.LogicalDeviceAliases.Values.Any(x => string.IsNullOrWhiteSpace(x)) || + identity.LogicalDeviceAliases.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count() != observed.Count) + throw new InvalidDataException("SCL identity evidence is not a unique complete MMS-domain mapping."); + + // Existing authoritative normalization proves identity and preserves the + // MMS wire-domain graph. Transform LDevice.inst and FCDA LD references + // together so that a single-domain BCUGEF650 becomes BCUGE/F650. + var document = ApplyIdentity(generated, model, identity.IedName); + var rewrites = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var ied = document.Root?.Element(Scl + "IED") + ?? throw new InvalidDataException("SCL has no unique IED."); + var nodes = ied.Descendants(Scl + "LDevice").ToArray(); + if (nodes.Length != observed.Count) + throw new InvalidDataException("Generated LD set and observed MMS domain set differ."); + + foreach (var ld in nodes) + { + var oldInst = ((string?)ld.Attribute("inst") ?? "").Trim(); + var explicitDomain = ((string?)ld.Attribute("ldName") ?? "").Trim(); + var domain = explicitDomain.Length != 0 ? explicitDomain : identity.IedName + oldInst; + if (!identity.LogicalDeviceAliases.TryGetValue(domain, out var newInst) || + !seen.Add(domain)) + throw new InvalidDataException($"Generated SCL domain '{domain}' is not uniquely matched."); + rewrites.Add(oldInst, newInst); + ld.SetAttributeValue("inst", newInst); + ld.SetAttributeValue("ldName", + domain.Equals(identity.IedName + newInst, StringComparison.OrdinalIgnoreCase) + ? null : domain); + } + if (seen.Count != observed.Count) + throw new InvalidDataException("Generated SCL did not map every observed MMS domain."); + + // FCDA ldInst references refer to LDevice.inst, not the MMS domain. + // Never rewrite references belonging to an explicitly different IED. + foreach (var fcda in ied.Descendants(Scl + "FCDA")) + { + var fcdaIed = ((string?)fcda.Attribute("iedName") ?? "").Trim(); + if (fcdaIed.Length > 0 && + !fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase) && + !fcdaIed.Equals(identity.IedName, StringComparison.OrdinalIgnoreCase)) + continue; + var oldRef = ((string?)fcda.Attribute("ldInst") ?? "").Trim(); + if (rewrites.TryGetValue(oldRef, out var mapped)) + fcda.SetAttributeValue("ldInst", mapped); + if (fcdaIed.Equals(model.IedName, StringComparison.OrdinalIgnoreCase)) + fcda.SetAttributeValue("iedName", identity.IedName); + } + ValidateIdentity(document, identity.IedName, model); + return document; + } + public static XDocument ApplyReportControlConfiguration( XDocument source, LiveIedModelDiscoveryDocument model, diff --git a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs index c6b6ac3f..ad2c7838 100644 --- a/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs +++ b/src/AR.Iec61850/Scl/Export/LiveIedSclExportModels.cs @@ -15,6 +15,13 @@ public sealed class LiveIedSclExportOptions /// when an implementation uses explicit communication-level LD names. ///
public string IedNameOverride { get; init; } = string.Empty; + + /// + /// Optional evidence proved against the exact observed MMS domain set by + /// TrustedSclIedIdentityMatcher. Not a display alias or an IP-based guess. + /// Export fails closed on incomplete/colliding aliases. + /// + public AR.Iec61850.Discovery.LiveIedIdentity? VerifiedIdentity { get; init; } public string IpSubnet { get; init; } = "255.255.255.0"; public string IpGateway { get; init; } = "0.0.0.0"; public string OsiApTitle { get; init; } = string.Empty; diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs new file mode 100644 index 00000000..62274a78 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -0,0 +1,172 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; +using System.Xml.Linq; + +namespace AR.Iec61850.Tests.Discovery; + +public sealed class TrustedSclIedIdentityMatcherTests +{ + private const string SclNs = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void GE_F650_Single_Domain_Uses_SCL_Evidence_Not_Vendor_Suffix_Heuristic() + { + var inferred = LiveIedIdentityResolver.Resolve(["BCUGEF650"], "192.16.1.33"); + Assert.Equal("HostFallback", inferred.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Low,inferred.Confidence); + + var identity = TrustedSclIedIdentityMatcher.TryMatch( + Design("BCUGE", "F650", "192.16.1.33"), ["BCUGEF650"], "192.16.1.33"); + Assert.NotNull(identity); + Assert.Equal("BCUGE",identity.IedName); + Assert.Equal("F650",identity.LogicalDeviceAliases["BCUGEF650"]); + Assert.Equal("TrustedSclExactDomainMatch",identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,identity.Confidence); + } + + [Fact] + public void Verified_F650_Export_RoundTrips_Mms_Domain_And_Fcda_LdInst() + { + var model = Model("IED_192_16_1_33","BCUGEF650"); + var identity = TrustedSclIedIdentityMatcher.TryMatch( + Design("BCUGE","F650","192.16.1.33"),["BCUGEF650"],"192.16.1.33"); + Assert.NotNull(identity); + + var generated = LiveIedSclExporter.BuildDocument(model, + new LiveIedSclExportOptions { SchemaProfile = SclSchemaProfile.Edition1V16 }); + var normalized = AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity(generated,model,identity); + XNamespace ns=SclNs; + var ied=Assert.Single(normalized.Root!.Elements(ns+"IED")); + Assert.Equal("BCUGE",(string?)ied.Attribute("name")); + var device=Assert.Single(ied.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)device.Attribute("inst")); + Assert.Null(device.Attribute("ldName")); // implicit MMS domain BCUGEF650 + Assert.Equal("BCUGE",(string?)Assert.Single(normalized.Descendants(ns+"ConnectedAP")).Attribute("iedName")); + var fcda=Assert.Single(normalized.Descendants(ns+"FCDA")); + Assert.Equal("F650",(string?)fcda.Attribute("ldInst")); + } + + [Fact] + public void Explicit_LdName_Remains_Exact_When_Different_From_Implicit_Mms_Domain() + { + var scl=Design("BCUGE","F650","192.16.1.33","CUSTOM_WIRE_DOMAIN"); + var identity=TrustedSclIedIdentityMatcher.TryMatch(scl, + ["CUSTOM_WIRE_DOMAIN"],"192.16.1.33"); + Assert.NotNull(identity); + var model=Model("IED_192_16_1_33","CUSTOM_WIRE_DOMAIN"); + var doc=AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity( + LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()),model,identity); + XNamespace ns=SclNs; + var ld=Assert.Single(doc.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)ld.Attribute("inst")); + Assert.Equal("CUSTOM_WIRE_DOMAIN",(string?)ld.Attribute("ldName")); + Assert.Equal("F650",(string?)Assert.Single(doc.Descendants(ns+"FCDA")).Attribute("ldInst")); + } + + [Fact] + public void Different_Host_Or_Missing_Domain_Or_Wrong_Selected_IED_Fails_Closed() + { + var scl=Design("BCUGE","F650","192.16.1.33"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.34")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650","BCUGECTRL"],"192.16.1.33")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33","OTHER_IED")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["DIFFERENT"],"192.16.1.33")); + } + + [Fact] + public void Ambiguous_Duplicate_AccessPoints_And_Conflicting_Sources_Are_Not_Promoted() + { + XNamespace ns=SclNs; + var scl=Design("BCUGE","F650","192.16.1.33"); + var ied=Assert.Single(scl.Descendants(ns+"IED")); + var ap=Assert.Single(ied.Elements(ns+"AccessPoint")); + var duplicate=new XElement(ap); + duplicate.SetAttributeValue("name","S2"); + ied.Add(duplicate); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl,["BCUGEF650"],"192.16.1.33")); + + var model=Model("IED_192_16_1_33","BCUGEF650"); + var unverified=LiveIedIdentityResolver.Resolve(["BCUGEF650"],"192.16.1.33"); + Assert.Throws(() => + AuthoritativeLiveIedSclExporter.ApplyVerifiedIdentity( + LiveIedSclExporter.BuildDocument(model,new LiveIedSclExportOptions()), + model,unverified)); + } + + [Fact] + public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged() + { + var old=LiveIedIdentityResolver.Resolve( + ["OLSF501LD0","OLSF501CTRL"],"192.0.2.10"); + Assert.Equal("OLSF501",old.IedName); + var other=LiveIedIdentityResolver.Resolve( + ["OCR7SJ8Application","OCR7SJ8CB1","OCR7SJ8Mod2_MU1"], + "192.0.2.12"); + Assert.Equal("OCR7SJ8",other.IedName); + } + + private static XDocument Design(string ied,string inst,string ip,string? ldName=null) + { + XNamespace ns=SclNs; + return new XDocument(new XElement(ns+"SCL", + new XElement(ns+"Communication", + new XElement(ns+"SubNetwork", + new XElement(ns+"ConnectedAP", + new XAttribute("iedName",ied),new XAttribute("apName","S1"), + new XElement(ns+"Address", + new XElement(ns+"P",new XAttribute("type","IP"),ip))))), + new XElement(ns+"IED",new XAttribute("name",ied), + new XElement(ns+"AccessPoint",new XAttribute("name","S1"), + new XElement(ns+"Server", + new XElement(ns+"LDevice", + new XAttribute("inst",inst), + ldName is null ? null : new XAttribute("ldName",ldName), + new XElement(ns+"LN0", + new XAttribute("lnType","LLN0_type")))))))); + } + + private static LiveIedModelDiscoveryDocument Model(string ied,string domain) + => new() + { + Host="192.16.1.33", + IedName=ied, + AccessPointName="S1", + LogicalDevices=[ + new LiveIedLogicalDeviceModel + { + MmsDomain=domain, + Inst=domain, + LogicalNodes=[ + new LiveIedLogicalNodeModel + { + Name="LLN0", + LnClass="LLN0", + ProposedLnTypeId="LN_LLN0_type" + }, + new LiveIedLogicalNodeModel + { + Name="CSWI6", + LnClass="CSWI", + LnInst="6", + ProposedLnTypeId="LN_CSWI6_type" + } + ] + } + ], + DataSets=[ + new LiveIedDataSetModel + { + Reference=$"{domain}/LLN0.Digital", + Domain=domain,LogicalNode="LLN0",Name="Digital", + MemberCount=1, + Members=[ + new LiveIedDataSetMemberModel + { + Index=0,Reference=$"{domain}/CSWI6.Pos.stVal", + FunctionalConstraint="ST" + } + ] + } + ] + }; +} From d34757ee07313c567b452486280280e5d94c7b62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 06:58:01 +0700 Subject: [PATCH 099/176] fix(scl): thread verified IED/LD identity through canonical live export and instance values --- .../Scl/Export/CanonicalLiveIedSclExporter.cs | 42 +++++++++++++------ .../CanonicalLiveIedSclExporterTests.cs | 42 +++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs index 925cbe11..ae31f67b 100644 --- a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs @@ -19,7 +19,8 @@ public static LiveIedSclExportResult WriteFiles( LiveIedCanonicalModel canonical, string sclPath, SclSchemaProfile schemaProfile = SclSchemaProfile.Edition2V31, - string profile = "safe-connection") + string profile = "safe-connection", + LiveIedIdentity? verifiedIdentity = null) { ArgumentNullException.ThrowIfNull(canonical); ValidateCanonicalCommunication(canonical); @@ -41,7 +42,8 @@ public static LiveIedSclExportResult WriteFiles( ? "StationBus" : communication.SubNetworkName.Trim(), IpAddress = communication.Host.Trim(), - IedNameOverride = canonical.IedName, + IedNameOverride = verifiedIdentity?.IedName ?? canonical.IedName, + VerifiedIdentity = verifiedIdentity, IpSubnet = communication.IpSubnet?.Trim() ?? string.Empty, IpGateway = communication.IpGateway?.Trim() ?? string.Empty, OsiApTitle = association.ApTitle.Trim(), @@ -53,10 +55,10 @@ public static LiveIedSclExportResult WriteFiles( }); var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); - ApplyCanonicalCommunication(document, canonical); + ApplyCanonicalCommunication(document, canonical, verifiedIdentity?.IedName); PreserveRuntimeServiceCapacity(document, canonical.Discovery); - ApplyCanonicalInstanceValues(document, canonical); - ValidateRoundTripAssociation(document, canonical); + ApplyCanonicalInstanceValues(document, canonical, verifiedIdentity); + ValidateRoundTripAssociation(document, canonical, verifiedIdentity?.IedName); document.Save(result.SclPath); return result; } @@ -106,11 +108,12 @@ public static void ValidateCanonicalCommunication(LiveIedCanonicalModel canonica throw new InvalidDataException(string.Join(" | ", errors)); } - private static void ApplyCanonicalCommunication(XDocument document, LiveIedCanonicalModel canonical) + private static void ApplyCanonicalCommunication( + XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName) { var connectedAp = document.Descendants(Scl + "ConnectedAP").SingleOrDefault() ?? throw new InvalidDataException("Generated SCL must contain exactly one ConnectedAP."); - connectedAp.SetAttributeValue("iedName", canonical.IedName); + connectedAp.SetAttributeValue("iedName", trustedIedName ?? canonical.IedName); connectedAp.SetAttributeValue("apName", canonical.AccessPointName); var subNetwork = connectedAp.Parent; @@ -163,7 +166,8 @@ public static void PreserveRuntimeServiceCapacity( public static void ApplyCanonicalInstanceValues( XDocument document, - LiveIedCanonicalModel canonical) + LiveIedCanonicalModel canonical, + LiveIedIdentity? verifiedIdentity = null) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(canonical); @@ -212,7 +216,7 @@ public static void ApplyCanonicalInstanceValues( if (logicalNodeModel is null) continue; - var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain); + var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain, verifiedIdentity); if (lDevice is null) continue; @@ -272,9 +276,20 @@ public static void ApplyCanonicalInstanceValues( private static XElement? FindExportedLogicalDevice( XDocument document, LiveIedCanonicalModel canonical, - string domain) + string domain, + LiveIedIdentity? verifiedIdentity) { var normalizedDomain = domain.Trim(); + // A verified engineering source provides the exact LD instance. + // Avoid recomputing it from the canonical *provisional* IED name. + if (verifiedIdentity is not null && + verifiedIdentity.LogicalDeviceAliases.TryGetValue(normalizedDomain,out var trustedInst)) + { + var trusted = document.Descendants(Scl + "LDevice") + .Where(ld => string.Equals((string?)ld.Attribute("inst"),trustedInst,StringComparison.OrdinalIgnoreCase)) + .ToArray(); + return trusted.Length == 1 ? trusted[0] : null; + } var iedName = canonical.IedName.Trim(); var stripped = normalizedDomain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) && normalizedDomain.Length > iedName.Length @@ -462,12 +477,13 @@ private static bool TryFormatScalarValue( } } - private static void ValidateRoundTripAssociation(XDocument document, LiveIedCanonicalModel canonical) + private static void ValidateRoundTripAssociation( + XDocument document, LiveIedCanonicalModel canonical, string? trustedIedName) { var profiles = SclMmsAssociationProfileReader.Read(document); - var remote = profiles.Find(canonical.IedName, canonical.AccessPointName) + var remote = profiles.Find(trustedIedName ?? canonical.IedName, canonical.AccessPointName) ?? throw new InvalidDataException( - $"Generated SCL cannot round-trip its own ConnectedAP '{canonical.IedName}/{canonical.AccessPointName}'."); + $"Generated SCL cannot round-trip its own ConnectedAP '{trustedIedName ?? canonical.IedName}/{canonical.AccessPointName}'."); var canonicalAssociation = canonical.Communication.Association; var roundTripErrors = new List(); diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs index c6d7ed27..ca90f1a1 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -10,6 +10,48 @@ public sealed class CanonicalLiveIedSclExporterTests { private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + [Fact] + public void VerifiedSclIdentity_KeepsAcceptedMmsAssociationWhileMappingLogicalDevice() + { + var canonical = CreateCanonical(); + var observedDomain = canonical.Discovery.LogicalDevices.Single().MmsDomain; + XNamespace ns = Scl; + var trusted = new XDocument(new XElement(ns+"SCL", + new XElement(ns+"Communication",new XElement(ns+"SubNetwork", + new XElement(ns+"ConnectedAP", + new XAttribute("iedName","BCUGE"),new XAttribute("apName","AP1"), + new XElement(ns+"Address", + new XElement(ns+"P",new XAttribute("type","IP"),"10.20.30.40"))))), + new XElement(ns+"IED",new XAttribute("name","BCUGE"), + new XElement(ns+"AccessPoint",new XAttribute("name","AP1"), + new XElement(ns+"Server",new XElement(ns+"LDevice", + new XAttribute("inst","F650"), + new XAttribute("ldName",observedDomain))))))); + var proof = TrustedSclIedIdentityMatcher.TryMatch( + trusted,[observedDomain],"10.20.30.40","BCUGE"); + Assert.NotNull(proof); + + var dir=Path.Combine(Path.GetTempPath(),"ariec-p10-canonical-"+Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var result=CanonicalLiveIedSclExporter.WriteFiles(canonical, + Path.Combine(dir,"BCUGE.iid"),SclSchemaProfile.Edition1V16, + verifiedIdentity:proof); + var doc=XDocument.Load(result.SclPath); + Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"IED")).Attribute("name")); + var ld=Assert.Single(doc.Descendants(ns+"LDevice")); + Assert.Equal("F650",(string?)ld.Attribute("inst")); + Assert.Equal(observedDomain,(string?)ld.Attribute("ldName")); + Assert.Equal("BCUGE",(string?)Assert.Single(doc.Descendants(ns+"ConnectedAP")).Attribute("iedName")); + Assert.NotNull(SclMmsAssociationProfileReader.Read(doc).Find("BCUGE",canonical.AccessPointName)); + } + finally + { + Directory.Delete(dir,true); + } + } + [Fact] public void CanonicalBuilder_Preserves_CaseDistinct_InitialFcLeafEvidence() { From 00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 07:02:54 +0700 Subject: [PATCH 100/176] test(identity): construct canonical fixture with an observed logical device --- .../CanonicalLiveIedSclExporterTests.cs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs index ca90f1a1..1010e7d7 100644 --- a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -13,8 +13,29 @@ public sealed class CanonicalLiveIedSclExporterTests [Fact] public void VerifiedSclIdentity_KeepsAcceptedMmsAssociationWhileMappingLogicalDevice() { - var canonical = CreateCanonical(); - var observedDomain = canonical.Discovery.LogicalDevices.Single().MmsDomain; + var accepted = CreateCanonical(); + const string observedDomain = "IEDLD0"; + var canonical = new LiveIedCanonicalModel + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = "10.20.30.40", IedName = "IED", AccessPointName = "AP1", + LogicalDevices = [ + new LiveIedLogicalDeviceModel + { + MmsDomain = observedDomain, Inst = observedDomain, + LogicalNodes = [ + new LiveIedLogicalNodeModel + { + Name = "LLN0", LnClass = "LLN0", + ProposedLnTypeId = "LN_LLN0" + } + ] + } + ] + }, + Communication = accepted.Communication + }; XNamespace ns = Scl; var trusted = new XDocument(new XElement(ns+"SCL", new XElement(ns+"Communication",new XElement(ns+"SubNetwork", From 55594062deba1bb3f2c57f7fcdebdf1ba33a559c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 19:59:44 +0700 Subject: [PATCH 101/176] fix(simulation): serialize MMS InformationReport emission per association without per-report tasks --- .../MmsServer/MmsReportingRuntime.cs | 61 +++++++++++++---- .../Simulation/MmsReportingRuntimeTests.cs | 65 +++++++++++++++++++ 2 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs index 87f4a9ea..5c70f4f0 100644 --- a/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs +++ b/src/AR.Iec61850.Simulation/MmsServer/MmsReportingRuntime.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Threading.Channels; using AR.Iec61850.Asn1; using AR.Iec61850.Mms; @@ -192,6 +193,17 @@ public sealed class MmsAssociationReportingRuntime : IMmsAssociationRuntime, IDi private readonly Dictionary _integrityTimers = new(StringComparer.OrdinalIgnoreCase); private readonly object _gate = new(); private readonly CancellationTokenSource _cts = new(); + // One FIFO per MMS association. A distinct Task.Run per report previously + // allowed send delegates to acquire the socket lock in a different order + // from the SqNum assignment, manufacturing on-wire discontinuities. + private readonly Channel<(MmsRcbRuntimeState State, byte Reason)> _sendQueue = + Channel.CreateUnbounded<(MmsRcbRuntimeState, byte)>( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); private bool _disposed; public MmsAssociationReportingRuntime( @@ -226,6 +238,12 @@ public MmsAssociationReportingRuntime( IntgPd = (uint)Math.Max(0, rcb.IntegrityPeriodMs) }; } + + // One asynchronous worker is allocated per association, not per report. + // Capture the token here: a delayed worker must never access a CTS + // after Dispose() has disposed its source. + var senderToken = _cts.Token; + _ = Task.Run(() => DrainReportQueueAsync(senderToken)); } public IReadOnlyCollection States => _states.Values; @@ -414,6 +432,7 @@ public void Dispose() return; _disposed = true; + _sendQueue.Writer.TryComplete(); _cts.Cancel(); lock (_gate) { @@ -449,21 +468,39 @@ private void QueueReport(MmsRcbRuntimeState state, byte reason) if (_disposed) return; - _ = Task.Run(async () => + // Channel FIFO is the report ordering authority. No per-report Task.Run, + // no competing asynchronous socket writers and no silent report drop. + _sendQueue.Writer.TryWrite((state, reason)); + } + + private async Task DrainReportQueueAsync(CancellationToken cancellationToken) + { + try { - try - { - await SendReportAsync(state, reason, _cts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Association closing. - } - catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException or InvalidOperationException) + await foreach (var request in _sendQueue.Reader.ReadAllAsync(cancellationToken) + .ConfigureAwait(false)) { - _activity?.Invoke(state.MmsReference, false, $"InformationReport send failed: {ex.Message}"); + try + { + await SendReportAsync(request.State, request.Reason, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or + System.Net.Sockets.SocketException or InvalidOperationException) + { + _activity?.Invoke(request.State.MmsReference, false, + $"InformationReport send failed: {ex.Message}"); + } } - }, CancellationToken.None); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Association closing: pending reports are not sent on a dead socket. + } } private async Task SendReportAsync(MmsRcbRuntimeState state, byte reason, CancellationToken cancellationToken) diff --git a/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs b/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs index 059f3fe3..f7d86174 100644 --- a/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs +++ b/tests/AR.Iec61850.Tests/Simulation/MmsReportingRuntimeTests.cs @@ -128,6 +128,71 @@ public async Task ReportingRuntime_RequiresExplicitGiAndEmitsStandardReasonBits( Assert.Equal((byte)0x08, reason.RawValue[1]); } + [Fact] + public async Task ReportingRuntime_ConcurrentGiBurst_IsSerializedInWireOrder() + { + var enteredFirstSend = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirstSend = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var twoReportsSent = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sent = new List(); + var activeSends = 0; + var maxConcurrentSends = 0; + + using var runtime = new MmsAssociationReportingRuntime( + () => CreateSession(), + async (payload, ct) => + { + var active = Interlocked.Increment(ref activeSends); + maxConcurrentSends = Math.Max(maxConcurrentSends, active); + try + { + if (active == 1 && sent.Count == 0) + { + enteredFirstSend.TrySetResult(); + await releaseFirstSend.Task.WaitAsync(ct); + } + + lock (sent) + { + sent.Add(payload); + if (sent.Count == 2) + twoReportsSent.TrySetResult(); + } + } + finally + { + Interlocked.Decrement(ref activeSends); + } + }); + var rcb = Assert.Single(runtime.States, state => !state.Definition.Buffered); + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$RptEna", MmsDataValue.Boolean(true), out var enabled)); + Assert.Equal(0, enabled); + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$GI", MmsDataValue.Boolean(true), out var first)); + Assert.Equal(0, first); + await enteredFirstSend.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True(runtime.TryWriteRcbAttribute( + $"{rcb.MmsReference}$GI", MmsDataValue.Boolean(true), out var second)); + Assert.Equal(0, second); + + // If a per-report fire-and-forget Task can overtake the blocked first + // send, the observed active sender count or frame order would fail. + await Task.Delay(100); + Assert.Equal(1, Volatile.Read(ref activeSends)); + Assert.Equal(1, maxConcurrentSends); + releaseFirstSend.TrySetResult(); + await twoReportsSent.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var decoded = sent.Select(payload => + MmsReportFrameMapper.DecodeHeader(MmsInformationReportDecoder.Decode(payload))) + .ToArray(); + Assert.Equal(new ulong?[] { 1, 2 }, decoded.Select(frame => frame.SequenceNumber)); + Assert.Equal(1, maxConcurrentSends); + Assert.All(decoded, frame => Assert.Equal(rcb.RptId, frame.ReportId)); + } + [Fact] public void ReportControlBlockLayout_UsesExpectedPackedListWidths() { From ef0fe44f3b40e47dc18b1aac4476b635e67d531e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 20:02:03 +0700 Subject: [PATCH 102/176] ci: validate engine staging pull requests in existing Windows .NET workflow --- .github/workflows/dotnet-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index dbd7b027..f10c0ab4 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -20,7 +20,7 @@ on: - "*.slnx" - "Directory.Build.props" pull_request: - branches: [ main ] + branches: [ main, feat/495-dataset-type-closure ] workflow_dispatch: permissions: From fa93c9fdc7cd98fd3624227ee21c3eefc68bcac7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 11 Oct 2026 20:09:45 +0700 Subject: [PATCH 103/176] fix(mms): quarantine contradictory RptID/DatSet reports before process-value projection --- .../Mms/MmsPersistentReportMonitor.cs | 32 +++++++++- .../Mms/MmsPersistentReportRoutingTests.cs | 62 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index 403ed96f..d5ec0490 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -669,6 +669,22 @@ private void RoutePersistentInformationReport(byte[] payload) return null; } + var dataSet = NormalizeRoutingReference(header.DataSetReference); + + // Report identity is a two-field contract whenever the peer provides + // a domain-qualified DatSet. A matching RptID is not sufficient to + // project another DataSet's payload using this monitor's member order. + // Keep the existing RptID-only compatibility for omitted or abbreviated + // DatSet fields (some supported peers do not advertise a full path). + bool ConflictsWithAdvertisedDataSet(MmsPersistentReportMonitorSession candidate) + { + if (string.IsNullOrWhiteSpace(dataSet) || !dataSet.Contains('/')) + return false; + var expected = NormalizeRoutingReference(candidate.Plan.DataSetReference); + return !string.IsNullOrWhiteSpace(expected) && + !expected.Equals(dataSet, StringComparison.OrdinalIgnoreCase); + } + var reportId = NormalizeRoutingReference(header.ReportId); if (!string.IsNullOrWhiteSpace(reportId)) { @@ -677,6 +693,11 @@ private void RoutePersistentInformationReport(byte[] payload) .ToArray(); if (exactRptId.Length == 1) { + if (ConflictsWithAdvertisedDataSet(exactRptId[0])) + { + evidence = "RptID/DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "exact RptID"; return exactRptId[0]; } @@ -684,12 +705,16 @@ private void RoutePersistentInformationReport(byte[] payload) var rcbAffinity = candidates.Where(x => HasRcbNameAffinity(reportId, x.ReportControl)).ToArray(); if (rcbAffinity.Length == 1) { + if (ConflictsWithAdvertisedDataSet(rcbAffinity[0])) + { + evidence = "RptID/DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "RptID-to-RCB name affinity"; return rcbAffinity[0]; } } - var dataSet = NormalizeRoutingReference(header.DataSetReference); if (!string.IsNullOrWhiteSpace(dataSet)) { var exactDataSet = candidates @@ -714,6 +739,11 @@ private void RoutePersistentInformationReport(byte[] payload) if (candidates.Length == 1) { + if (ConflictsWithAdvertisedDataSet(candidates[0])) + { + evidence = "single-monitor DatSet conflict: report was quarantined before process-value projection"; + return null; + } evidence = "single active monitor fallback"; return candidates[0]; } diff --git a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs index 14ec1f56..2fccfc4b 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs @@ -19,6 +19,68 @@ public void Selector_RoutesByExactReportIdBeforeDataSetProjection() Assert.Equal("exact RptID", evidence); } + [Fact] + public void Selector_RejectsContradictoryDomainQualifiedDataSetWithExactReportId() + { + var first = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + var second = CreateSession("URCBA02", "RPT_B", "IEDLD0/LLN0.DS_B"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [first, second], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "IEDLD0/LLN0$DS_B" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("RptID/DatSet conflict", evidence); + } + + [Fact] + public void Selector_RejectsUnknownFullDataSetDespiteMatchingReportId() + { + var first = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + var second = CreateSession("URCBA02", "RPT_B", "IEDLD0/LLN0.DS_B"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [first, second], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "OTHERLD/LLN0$DS_X" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("quarantined", evidence); + } + + [Fact] + public void Selector_SingleActiveMonitor_DoesNotBypassDataSetConsistency() + { + var session = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + + var selected = MmsClientSession.SelectPersistentReportMonitor( + [session], + new MmsReportHeader { ReportId = "UNRECOGNIZED", DataSetReference = "OTHERLD/LLN0.DS_X" }, + out var evidence); + + Assert.Null(selected); + Assert.Contains("single-monitor DatSet conflict", evidence); + } + + [Fact] + public void Selector_PreservesValidNoDataSetAndVendorShortDataSetCompatibility() + { + var session = CreateSession("URCBA01", "RPT_A", "IEDLD0/LLN0.DS_A"); + + var missing = MmsClientSession.SelectPersistentReportMonitor( + [session], new MmsReportHeader { ReportId = "RPT_A" }, out var missingEvidence); + var shortName = MmsClientSession.SelectPersistentReportMonitor( + [session], + new MmsReportHeader { ReportId = "RPT_A", DataSetReference = "LLN0$DS_A" }, + out var shortEvidence); + + Assert.Same(session, missing); + Assert.Same(session, shortName); + Assert.Equal("exact RptID", missingEvidence); + Assert.Equal("exact RptID", shortEvidence); + } + [Fact] public void Selector_RoutesByExactDataSetWhenReportIdIsUnavailable() { From d700b0df741b466afb8abe6cee1d70b1619d7b4d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 05:30:32 +0700 Subject: [PATCH 104/176] fix(mms): fail closed instead of silently dropping overflowed report subscriber frames --- src/AR.Iec61850/Mms/MmsReceiveRouter.cs | 25 ++++- .../Mms/MmsReceiveRouterTests.cs | 95 ++++++++++++++++++- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs index 402ad4fc..cf1e496c 100644 --- a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs +++ b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs @@ -90,8 +90,25 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload) { _informationReports.Enqueue(envelope); SignalInformationReportAvailable(); - foreach (var subscription in _informationReportSubscriptions.Values) - subscription.Writer.TryWrite(envelope); + // A subscriber cannot silently discard SOE on burst pressure. + // Preserve the canonical FIFO regardless of subscriber capacity; + // isolate and fault only consumers which cannot keep up. + List? overflowed = null; + foreach (var (id, subscription) in _informationReportSubscriptions) + { + if (!subscription.Writer.TryWrite(envelope)) + { + (overflowed ??= new List()).Add(id); + subscription.Writer.TryComplete(new IOException( + "MMS InformationReport subscriber exceeded its bounded capacity; " + + "continuity is unverified. Resubscribe/re-establish the report stream.")); + } + } + if (overflowed is not null) + { + foreach (var id in overflowed) + _informationReportSubscriptions.Remove(id); + } return new MmsReceiveRouteResult { @@ -184,7 +201,9 @@ internal MmsInformationReportSubscription SubscribeInformationReports(int capaci var boundedCapacity = Math.Clamp(capacity, 1, 1024); var channel = Channel.CreateBounded(new BoundedChannelOptions(boundedCapacity) { - FullMode = BoundedChannelFullMode.DropOldest, + // Never silently throw away the oldest event (SOE data loss). + // TryWrite=false is handled by explicitly faulting this subscriber. + FullMode = BoundedChannelFullMode.Wait, SingleReader = true, SingleWriter = false, AllowSynchronousContinuations = false diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs index 4910d871..509938ad 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs @@ -99,6 +99,97 @@ public async Task Route_FansOutInformationReportWithoutStealingLegacyQueue() Assert.True(queued.IsInformationReport); } + [Fact] + public async Task SubscriberBurstOverCapacity_FaultsWithoutSilentDrop_WhileCanonicalFifoRetainsEveryFrame() + { + var router = new MmsReceiveRouter(); + await using var slow = router.SubscribeInformationReports(capacity: 8); + var sent = Enumerable.Range(1, 80).Select(BuildInformationReport).ToArray(); + + foreach (var bytes in sent) + Assert.Equal(MmsReceiveRouteAction.QueuedInformationReport, router.Route(bytes).Action); + + // The bounded observer has *all eight original* frames, not the eight + // newest; its terminal error explicitly declares the missing 72. + for (var i = 0; i < 8; i++) + { + Assert.True(slow.Reader.TryRead(out var envelope)); + Assert.Equal(sent[i], envelope.PresentationPayload); + } + + Assert.False(slow.Reader.TryRead(out _)); + var overflow = await Assert.ThrowsAsync(async () => + await slow.Reader.Completion.WaitAsync(TimeSpan.FromSeconds(2))); + Assert.Contains("exceeded its bounded capacity", overflow.Message); + + // The actual static-monitor source of truth MUST NEVER lose those + // frames when an optional observer has fallen behind. + Assert.Equal(80, router.QueuedInformationReportCount); + for (var i = 0; i < sent.Length; i++) + { + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.Equal(sent[i], queued.PresentationPayload); + } + Assert.False(router.TryDequeueInformationReport(out _)); + } + + [Fact] + public async Task SubscriberOverflow_IsolatedFromFastSubscriberAndConfirmedMmsResponses() + { + var router = new MmsReceiveRouter(); + await using var slow = router.SubscribeInformationReports(capacity: 1); + await using var fast = router.SubscribeInformationReports(capacity: 1); + + for (var i = 1; i <= 16; i++) + { + var frame = BuildInformationReport(i); + router.Route(frame); + Assert.True(fast.Reader.TryRead(out var received)); + Assert.Equal(frame, received.PresentationPayload); + } + + router.Route(BuildConfirmedReadResponse(invokeId: 42)); + Assert.True(router.TryDequeueConfirmedResult(42, out var confirmation)); + Assert.Equal(42, confirmation.InvokeId); + Assert.False(fast.Reader.Completion.IsCompleted); + + Assert.True(slow.Reader.TryRead(out var original)); + Assert.Equal(BuildInformationReport(1), original.PresentationPayload); + await Assert.ThrowsAsync(async () => + await slow.Reader.Completion.WaitAsync(TimeSpan.FromSeconds(2))); + + // Faulting one slow observer cannot corrupt or drop canonical report + // order, even while confirmed-service responses arrive. + for (var i = 1; i <= 16; i++) + { + Assert.True(router.TryDequeueInformationReport(out var queued)); + Assert.Equal(BuildInformationReport(i), queued.PresentationPayload); + } + } + + [Fact] + public async Task SubscriberAtCapacity_DoesNotFalseFail_AndCanReceiveAfterDrain() + { + var router = new MmsReceiveRouter(); + await using var sub = router.SubscribeInformationReports(capacity: 2); + var a = BuildInformationReport(1); + var b = BuildInformationReport(2); + var c = BuildInformationReport(3); + router.Route(a); + router.Route(b); + + Assert.False(sub.Reader.Completion.IsCompleted); + Assert.True(sub.Reader.TryRead(out var first)); + Assert.Equal(a, first.PresentationPayload); + router.Route(c); + + Assert.True(sub.Reader.TryRead(out var second)); + Assert.True(sub.Reader.TryRead(out var third)); + Assert.Equal(b, second.PresentationPayload); + Assert.Equal(c, third.PresentationPayload); + Assert.False(sub.Reader.Completion.IsCompleted); + } + [Fact] public async Task WaitForInformationReportAsync_ReturnsImmediately_WhenQueueAlreadyHasReport() { @@ -189,12 +280,12 @@ private static byte[] BuildConfirmedReadResponse(int invokeId) return MmsPresentation.WrapIsoPresentationPData(mms); } - private static byte[] BuildInformationReport() + private static byte[] BuildInformationReport(int sequence = 0) { var variableAccessSpecification = BerWriter.EncodeTlv( 0xA1, BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii("LD0")) - .Concat(BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii("LLN0$Events"))) + .Concat(BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii(sequence == 0 ? "LLN0$Events" : $"LLN0$Events{sequence:D3}"))) .ToArray()); var listOfAccessResult = BerWriter.EncodeTlv(0xA0, MmsDataCodec.Encode(MmsDataValue.Boolean(true))); var informationReport = BerWriter.EncodeTlv( From 9bd01908ebd6fdf50a74f0a421b4e79613b878d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 13:45:39 +0700 Subject: [PATCH 105/176] fix(mms): bound canonical report ingress and fail closed on overflow/receive fault --- src/AR.Iec61850/Mms/MmsReceivePump.cs | 1 + src/AR.Iec61850/Mms/MmsReceiveRouter.cs | 78 ++++++++++++++++++- .../Mms/MmsReceivePumpTests.cs | 24 ++++++ .../Mms/MmsReceiveRouterTests.cs | 57 ++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsReceivePump.cs b/src/AR.Iec61850/Mms/MmsReceivePump.cs index d6f68854..1937f127 100644 --- a/src/AR.Iec61850/Mms/MmsReceivePump.cs +++ b/src/AR.Iec61850/Mms/MmsReceivePump.cs @@ -183,6 +183,7 @@ private async Task RunAsync(CancellationToken cancellationToken) { LastFaultMessage = $"MMS receive pump fault: {ex.GetType().Name}: {ex.Message}"; FaultPendingOperations(ex); + _router.FaultCanonicalInformationReports(ex); _router.FaultInformationReportSubscriptions(ex); } } diff --git a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs index cf1e496c..523c85cc 100644 --- a/src/AR.Iec61850/Mms/MmsReceiveRouter.cs +++ b/src/AR.Iec61850/Mms/MmsReceiveRouter.cs @@ -21,12 +21,30 @@ public sealed class MmsReceiveRouter { private readonly object _sync = new(); private readonly Dictionary> _confirmedByInvoke = new(); + // The canonical FIFO is the static report monitor authority. Bound both frame count + // and retained PDU bytes; never silently discard a buffered event on overload. + private const int DefaultCanonicalCapacity = 2048; + private const long DefaultCanonicalByteCapacity = 32L * 1024 * 1024; + private readonly int _canonicalCapacity; + private readonly long _canonicalByteCapacity; + private long _queuedCanonicalBytes; + private Exception? _canonicalReportFault; private readonly Queue _informationReports = new(); private readonly Queue _unconfirmed = new(); private readonly Queue _unmatched = new(); private readonly Dictionary> _informationReportSubscriptions = new(); private readonly SemaphoreSlim _informationReportAvailable = new(0, 1); + public MmsReceiveRouter( + int canonicalReportCapacity = DefaultCanonicalCapacity, + long canonicalReportByteCapacity = DefaultCanonicalByteCapacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(canonicalReportCapacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(canonicalReportByteCapacity); + _canonicalCapacity = canonicalReportCapacity; + _canonicalByteCapacity = canonicalReportByteCapacity; + } + public int QueuedConfirmedResultCount { get @@ -69,6 +87,7 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload) lock (_sync) { + ThrowIfCanonicalReportFaultLocked(); if (envelope.IsConfirmedServiceResult && envelope.InvokeId.HasValue) { if (!_confirmedByInvoke.TryGetValue(envelope.InvokeId.Value, out var queue)) @@ -88,7 +107,19 @@ public MmsReceiveRouteResult Route(ReadOnlyMemory presentationPayload) if (envelope.IsInformationReport) { + var retainedBytes = envelope.PresentationPayload.Length; + if (_informationReports.Count >= _canonicalCapacity || + retainedBytes > _canonicalByteCapacity - _queuedCanonicalBytes) + { + _canonicalReportFault = new IOException( + "Canonical MMS InformationReport FIFO exceeded its bounded frame/byte capacity; " + + "SOE continuity is unverified. Re-establish the MMS association after addressing consumer lag."); + SignalInformationReportAvailable(); + throw (IOException)_canonicalReportFault; + } + _informationReports.Enqueue(envelope); + _queuedCanonicalBytes += retainedBytes; SignalInformationReportAvailable(); // A subscriber cannot silently discard SOE on burst pressure. // Preserve the canonical FIFO regardless of subscriber capacity; @@ -163,10 +194,14 @@ public bool TryDequeueInformationReport(out MmsPduEnvelope envelope) if (_informationReports.Count > 0) { envelope = _informationReports.Dequeue(); + _queuedCanonicalBytes -= envelope.PresentationPayload.Length; return true; } } + lock (_sync) + ThrowIfCanonicalReportFaultLocked(); + envelope = new MmsPduEnvelope(); return false; } @@ -182,18 +217,30 @@ internal async Task WaitForInformationReportAsync( { cancellationToken.ThrowIfCancellationRequested(); - if (QueuedInformationReportCount > 0) + lock (_sync) { - _informationReportAvailable.Wait(0); - return true; + if (_informationReports.Count > 0) + { + _informationReportAvailable.Wait(0); + return true; + } + + ThrowIfCanonicalReportFaultLocked(); } if (timeout <= TimeSpan.Zero) return false; - return await _informationReportAvailable + var signalled = await _informationReportAvailable .WaitAsync(timeout, cancellationToken) .ConfigureAwait(false); + lock (_sync) + { + if (_informationReports.Count == 0) + ThrowIfCanonicalReportFaultLocked(); + } + + return signalled; } internal MmsInformationReportSubscription SubscribeInformationReports(int capacity = 32) @@ -243,6 +290,27 @@ internal void FaultInformationReportSubscriptions(Exception exception) subscription.Writer.TryComplete(exception); } + // Called by the single receive pump on a terminal transport/decoder error. + // Existing canonical entries remain drainable in FIFO order; after the final + // entry the consumer observes the terminal error rather than a false clean slice. + internal void FaultCanonicalInformationReports(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + lock (_sync) + { + _canonicalReportFault ??= exception; + SignalInformationReportAvailable(); + } + } + + private void ThrowIfCanonicalReportFaultLocked() + { + if (_canonicalReportFault is not null) + throw new IOException( + "Canonical MMS InformationReport continuity is unverified; association receive routing has faulted.", + _canonicalReportFault); + } + private void SignalInformationReportAvailable() { try @@ -262,6 +330,8 @@ public void Clear() { _confirmedByInvoke.Clear(); _informationReports.Clear(); + _queuedCanonicalBytes = 0; + _canonicalReportFault = null; _unconfirmed.Clear(); _unmatched.Clear(); subscriptions = _informationReportSubscriptions.Values.ToArray(); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs index e00df8d8..41d121ca 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceivePumpTests.cs @@ -84,6 +84,30 @@ public async Task Pump_FaultsPendingInvokeWhenReaderFails() Assert.Equal(0, pump.PendingOperationCount); } + [Fact] + public async Task Pump_CanonicalOverflow_FaultsPendingConfirmedWorkAndRetainsQueuedReports() + { + var channel = Channel.CreateUnbounded(); + for (var i = 0; i < 4; i++) + await channel.Writer.WriteAsync(BuildInformationReport()); + + var router = new MmsReceiveRouter(canonicalReportCapacity: 2); + var pump = new MmsReceivePump(router, token => channel.Reader.ReadAsync(token).AsTask()); + using var pending = pump.RegisterConfirmedOperation(9); + pump.Start(); + + var failure = await Assert.ThrowsAsync( + () => pending.WaitAsync(new CancellationTokenSource(TimeSpan.FromSeconds(2)).Token)); + await pump.StopAsync(); + + Assert.Contains("bounded frame/byte capacity", failure.Message); + Assert.Equal(2, router.QueuedInformationReportCount); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + Assert.Contains("IOException", pump.LastFaultMessage); + } + private static byte[] BuildConfirmedReadResponse(int invokeId) { var data = MmsDataCodec.Encode(MmsDataValue.Boolean(true)); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs index 509938ad..80f53bc8 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReceiveRouterTests.cs @@ -266,6 +266,63 @@ await Assert.ThrowsAsync(async () => await subscription.Reader.Completion.WaitAsync(timeout.Token)); } + [Fact] + public void CanonicalCapacityExceeded_PreservesOriginalFifoAndFailsClosedUntilClear() + { + var router = new MmsReceiveRouter(canonicalReportCapacity: 3); + var original = Enumerable.Range(1, 3).Select(BuildInformationReport).ToArray(); + foreach (var report in original) + router.Route(report); + + var failure = Assert.Throws(() => router.Route(BuildInformationReport(4))); + Assert.Contains("bounded frame/byte capacity", failure.Message); + Assert.Equal(3, router.QueuedInformationReportCount); + + for (var i = 0; i < original.Length; i++) + { + Assert.True(router.TryDequeueInformationReport(out var report)); + Assert.Equal(original[i], report.PresentationPayload); + } + + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.Route(BuildConfirmedReadResponse(42))); + router.Clear(); + + Assert.Equal(0, router.QueuedInformationReportCount); + router.Route(BuildInformationReport(5)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.False(router.TryDequeueInformationReport(out _)); + } + + [Fact] + public void CanonicalByteCapacity_RejectsThirdReportWithoutUnboundedRetention() + { + var report = BuildInformationReport(1); + var router = new MmsReceiveRouter( + canonicalReportCapacity: 10, + canonicalReportByteCapacity: 2L * report.Length); + router.Route(report); + router.Route(report); + + Assert.Throws(() => router.Route(report)); + Assert.Equal(2, router.QueuedInformationReportCount); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.True(router.TryDequeueInformationReport(out _)); + Assert.Throws(() => router.TryDequeueInformationReport(out _)); + } + + [Fact] + public async Task CanonicalTerminalFault_WakesWaitingMonitorWithoutFalseTimeout() + { + var router = new MmsReceiveRouter(); + var waiting = router.WaitForInformationReportAsync(TimeSpan.FromSeconds(2)); + router.FaultCanonicalInformationReports(new IOException("scripted association failure")); + + var failure = await Assert.ThrowsAsync(() => waiting); + Assert.Contains("continuity is unverified", failure.Message); + Assert.Contains("scripted association failure", failure.InnerException?.Message); + } + private static byte[] BuildConfirmedReadResponse(int invokeId) { var data = MmsDataCodec.Encode(MmsDataValue.Boolean(true)); From 98f2119d6ec125d168aa54a767e8c8c229b5ef37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 14:39:15 +0700 Subject: [PATCH 106/176] fix(scl): decode unambiguous BER-packed AP-title in trusted association plan --- src/AR.Iec61850/Scl/SclApTitleOidNotation.cs | 68 +++++++++++++++++++ .../Scl/SclAssistedMmsAssociationPlan.cs | 29 +++----- .../SclAssistedMmsAssociationResolution.cs | 29 ++------ ...clAssistedMmsAssociationResolutionTests.cs | 48 +++++++++++++ 4 files changed, 132 insertions(+), 42 deletions(-) create mode 100644 src/AR.Iec61850/Scl/SclApTitleOidNotation.cs diff --git a/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs b/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs new file mode 100644 index 00000000..d22206e9 --- /dev/null +++ b/src/AR.Iec61850/Scl/SclApTitleOidNotation.cs @@ -0,0 +1,68 @@ +using System.Globalization; + +namespace AR.Iec61850.Scl; + +/// +/// SCL AP-Title is normally an ASN.1 OID arc list. Some legacy engineering +/// exports spell the first *BER subidentifier* as one decimal component +/// (40*firstArc + secondArc). Only the unambiguous single-octet 40..79 +/// form is recognized here; arbitrary malformed OIDs remain invalid. +/// No source XML or other SCL association field is rewritten. +/// +internal static class SclApTitleOidNotation +{ + internal static bool TryParse( + string text, + out uint[] arcs, + out bool wasPackedFirstArc, + out string error) + { + arcs = Array.Empty(); + wasPackedFirstArc = false; + error = string.Empty; + var tokens = text.Split( + [',', '.', ' ', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (tokens.Length < 2) + { + error = $"SCL OSI-AP-Title '{text}' does not contain a valid OID."; + return false; + } + + var declared = new uint[tokens.Length]; + for (var i = 0; i < tokens.Length; i++) + { + if (!uint.TryParse(tokens[i], NumberStyles.None, CultureInfo.InvariantCulture, out declared[i])) + { + error = $"SCL OSI-AP-Title '{text}' contains an invalid OID arc '{tokens[i]}'."; + return false; + } + } + + if (declared[0] <= 2 && (declared[0] == 2 || declared[1] <= 39)) + { + arcs = declared; + return true; + } + + // Legacy OID subidentifier: decimal 41 means ASN.1 arcs 1.1. + // At least one remaining arc is required to distinguish this + // representation from an incomplete engineering declaration. + if (declared.Length >= 3 && declared[0] is >= 40 and <= 79) + { + arcs = new uint[declared.Length + 1]; + arcs[0] = 1; + arcs[1] = declared[0] - 40; + Array.Copy(declared, 1, arcs, 2, declared.Length - 1); + wasPackedFirstArc = true; + return true; + } + + error = $"SCL OSI-AP-Title '{text}' violates ASN.1 object-identifier arc rules."; + return false; + } + + internal static string CompatibilityWarning(string declared, IReadOnlyList decoded) + => $"SCL OSI-AP-Title '{declared}' uses legacy BER-packed first subidentifier; " + + $"association encodes standard OID '{string.Join(",", decoded)}'. Original SCL declaration remains unchanged."; +} diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs index 265e436d..a0d97d2e 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationPlan.cs @@ -102,7 +102,7 @@ public static SclAssistedMmsAssociationPlanResult BuildExact( var remoteTsel = ParseSelector(remote.Association.TransportSelector, "OSI-TSEL", errors); var remoteSsel = ParseSelector(remote.Association.SessionSelector, "OSI-SSEL", errors); var remotePsel = ParseSelector(remote.Association.PresentationSelector, "OSI-PSEL", errors); - var remoteApTitle = ParseApTitle(remote.Association.ApTitle, errors); + var remoteApTitle = ParseApTitle(remote.Association.ApTitle, errors, warnings); if (!remote.Association.AeQualifier.HasValue || remote.Association.AeQualifier.Value is < 0 or > 65535) @@ -223,7 +223,10 @@ private static void ValidateLocal(MmsLocalAssociationProfile local, ICollection< return bytes; } - private static uint[]? ParseApTitle(string text, ICollection errors) + private static uint[]? ParseApTitle( + string text, + ICollection errors, + ICollection warnings) { if (string.IsNullOrWhiteSpace(text)) { @@ -231,28 +234,14 @@ private static void ValidateLocal(MmsLocalAssociationProfile local, ICollection< return null; } - var tokens = text.Split([',', '.', ' ', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (tokens.Length < 2) + if (!SclApTitleOidNotation.TryParse(text, out var arcs, out var packed, out var error)) { - errors.Add($"SCL OSI-AP-Title '{text}' does not contain a valid OID."); + errors.Add(error); return null; } - var arcs = new uint[tokens.Length]; - for (var i = 0; i < tokens.Length; i++) - { - if (!uint.TryParse(tokens[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out arcs[i])) - { - errors.Add($"SCL OSI-AP-Title '{text}' contains an invalid OID arc '{tokens[i]}'."); - return null; - } - } - - if (arcs[0] > 2 || (arcs[0] < 2 && arcs[1] > 39)) - { - errors.Add($"SCL OSI-AP-Title '{text}' violates ASN.1 object-identifier arc rules."); - return null; - } + if (packed) + warnings.Add(SclApTitleOidNotation.CompatibilityWarning(text, arcs)); return arcs; } diff --git a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs index 805061b0..432230e7 100644 --- a/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs +++ b/src/AR.Iec61850/Scl/SclAssistedMmsAssociationResolution.cs @@ -87,7 +87,7 @@ public static SclAssistedMmsAssociationResolution Resolve( var tsel = ParseSelector(remote, "OSI-TSEL", remote.Association.TransportSelector, errors); var ssel = ParseSelector(remote, "OSI-SSEL", remote.Association.SessionSelector, errors); var psel = ParseSelector(remote, "OSI-PSEL", remote.Association.PresentationSelector, errors); - var apTitle = ParseApTitle(remote, remote.Association.ApTitle, errors); + var apTitle = ParseApTitle(remote, remote.Association.ApTitle, errors, warnings); var aeQualifier = ParseAeQualifier(remote, errors); var fields = new[] @@ -548,7 +548,8 @@ private static SelectorConstraint InvalidSelector(string name, string text) private static ApTitleConstraint ParseApTitle( SclMmsAccessPoint remote, string text, - ICollection errors) + ICollection errors, + ICollection warnings) { const string name = "OSI-AP-Title"; if (remote.AmbiguousParameters.Contains(name)) @@ -568,30 +569,14 @@ private static ApTitleConstraint ParseApTitle( new SclAssociationFieldEvidence { Name = name, State = SclAssociationFieldState.Unspecified }); } - var tokens = text.Split( - [',', '.', ' ', ';'], - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (tokens.Length < 2) + if (!SclApTitleOidNotation.TryParse(text, out var arcs, out var packed, out var error)) { - errors.Add($"SCL OSI-AP-Title '{text}' does not contain a valid OID."); + errors.Add(error); return InvalidApTitle(text); } - var arcs = new uint[tokens.Length]; - for (var i = 0; i < tokens.Length; i++) - { - if (!uint.TryParse(tokens[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out arcs[i])) - { - errors.Add($"SCL OSI-AP-Title '{text}' contains an invalid OID arc '{tokens[i]}'."); - return InvalidApTitle(text); - } - } - - if (arcs[0] > 2 || (arcs[0] < 2 && arcs[1] > 39)) - { - errors.Add($"SCL OSI-AP-Title '{text}' violates ASN.1 object-identifier arc rules."); - return InvalidApTitle(text); - } + if (packed) + warnings.Add(SclApTitleOidNotation.CompatibilityWarning(text, arcs)); return new ApTitleConstraint( SclAssociationFieldState.PresentValid, diff --git a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs index bdd61755..b4266050 100644 --- a/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs +++ b/tests/AR.Iec61850.Tests/Scl/SclAssistedMmsAssociationResolutionTests.cs @@ -156,6 +156,54 @@ public void Resolve_Conflicting_Duplicate_Critical_Field_Fails_Closed() field.State == SclAssociationFieldState.Ambiguous); } + [Fact] + public void Resolve_ZivStylePackedFirstSubidentifier_ProducesExactWireOid_WithoutDiscovery() + { + var remote = BuildRemote( + tsel: "0001", + ssel: "0001", + psel: "00000001", + apTitle: "41,1,999,1", + aeQualifierText: "12", + aeQualifier: 12); + + var exact = SclAssistedMmsAssociationPlanBuilder.BuildExact( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.True(exact.IsSuccess, string.Join(" | ", exact.Errors)); + Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, exact.Plan!.Association.Called.ApTitle); + Assert.Contains(exact.Warnings, message => message.Contains("BER-packed", StringComparison.Ordinal)); + + var result = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.True(result.IsSuccess, string.Join(" | ", result.Errors)); + Assert.Equal(SclAssociationCandidateSource.ExactScl, result.Candidates[0].Source); + Assert.Contains(result.Warnings, message => + message.Contains("41,1,999,1", StringComparison.Ordinal) && + message.Contains("1,1,1,999,1", StringComparison.Ordinal)); + + var wire = AcseAssociationRequestIdentityReader.Read( + result.Candidates[0].AssociationProfile.Payload, + result.Candidates[0].Cotp.DestinationTsap); + Assert.Equal(new uint[] { 1, 1, 1, 999, 1 }, wire.CalledApTitle); + Assert.Equal(12, wire.CalledAeQualifier); + } + + [Theory] + [InlineData("80,1,999,1")] + [InlineData("41")] + [InlineData("41,invalid,999,1")] + [InlineData("3,1,999,1")] + public void Resolve_StillRejectsUnprovenMalformedApTitles(string title) + { + var remote = BuildRemote("0001", "0001", "00000001", title, "12", 12); + var resolution = SclAssistedMmsAssociationCandidateResolver.Resolve( + remote, MmsLocalAssociationProfile.ExistingRuntimeDefault); + Assert.False(resolution.IsSuccess); + Assert.Empty(resolution.Candidates); + Assert.Contains(resolution.Fields, f => f.Name == "OSI-AP-Title" && + f.State == SclAssociationFieldState.Invalid); + } + private static SclMmsAccessPoint BuildRemote( string tsel, string ssel, From 67c6635b7e16139c0661e559a2e6946527c1442a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 14:44:15 +0700 Subject: [PATCH 107/176] fix(control): identify BSC TapChg from live ValWithTrans status path --- .../Control/Iec61850ControlService.cs | 8 ++++++- .../Control/SmartControlStackTests.cs | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index 30b4051f..6b6aba9b 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -126,7 +126,12 @@ internal async Task OpenCoreAsync( var descriptor = new Iec61850ControlObjectDescriptor { ObjectReference = references.ObjectReference, - Cdc = InferCdc(ctlValSpecification), + // The exact live status shape distinguishes BSC TapChg's Tcmd enum + // from DPC even when both ctlVal types have a two-bit MMS encoding. + // Never classify a step-position value as an Open/Close switch. + Cdc = status.Reference.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase) + ? "BSC" + : InferCdc(ctlValSpecification), ControlModel = controlModel, CtlValSpecification = ctlValSpecification, OperSpecification = operSpecification, @@ -220,6 +225,7 @@ private static (string Reference, string FunctionalConstraint) FindStatusReferen { var prefixes = new[] { + $"{references.LogicalNode}$ST${references.MmsDataObjectPath}$valWTr$posVal", $"{references.LogicalNode}$ST${references.MmsDataObjectPath}$stVal", $"{references.LogicalNode}$ST${references.MmsDataObjectPath}$posVal", $"{references.LogicalNode}$MX${references.MmsDataObjectPath}$mag$f", diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index ead5a83d..4e4cb6f9 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -599,6 +599,30 @@ public async Task DescriptorDiscovery_UsesLiveCtlModelAndExactCtlValType() Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); } + [Fact] + public async Task DescriptorDiscovery_RecognizesBscStepControl_FromExactLiveValWTrPosition() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(4) }); + transport.Specifications["ATCC1$CO$TapChg$Oper"] = CommandSpecification(); + transport.Specifications["ATCC1$CO$TapChg$SBOw"] = CommandSpecification(); + transport.Specifications["ATCC1$CO$TapChg$Cancel"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] + { + "ATCC1$ST$TapChg$valWTr$posVal", + "ATCC1$ST$TapChg$q" + }; + var service = new Iec61850ControlService(); + + await using var session = await service.OpenCoreAsync(transport, "LD0/ATCC1.TapChg"); + + Assert.Equal("BSC", session.Descriptor.Cdc); + Assert.Equal(Iec61850ControlModel.SelectBeforeOperateEnhanced, session.Descriptor.ControlModel); + Assert.Equal("LD0/ATCC1.TapChg.valWTr.posVal", session.Descriptor.StatusReference); + Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); + Assert.Equal("bit-string", session.Descriptor.CtlValSpecification.MmsType); + } + [Fact] public async Task DescriptorDiscovery_RejectsSboObjectWithoutCancelService() { From a43b9b4864a9565c02f544d4135fe157c2fac577 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 16:39:18 +0700 Subject: [PATCH 108/176] P10.9K: gate generic control semantics and reuse per-association MMS inventory --- .../Iec61850ControlSemanticResolver.cs | 91 +++++++++++++++++++ ...lientSession.SmartDiscoverySingleFlight.cs | 55 ++++++++++- ...MultiVendorControlSemanticResolverTests.cs | 66 ++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs create mode 100644 tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs diff --git a/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs new file mode 100644 index 00000000..ed2ac090 --- /dev/null +++ b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs @@ -0,0 +1,91 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Control; + +/// +/// Resolves *command* semantics from a declared CDC and corroborating live MMS +/// evidence. A primitive MMS type is an encoding, not permission to Operate. +/// This is vendor-independent and never sends an MMS request. +/// +public static class Iec61850ControlSemanticResolver +{ + public static Iec61850ControlSemanticResolution Resolve( + Iec61850ControlObjectDescriptor descriptor, + string? declaredCdc, + string? declaredStatusReference = null) + { + ArgumentNullException.ThrowIfNull(descriptor); + var declared = (declaredCdc ?? string.Empty).Trim().ToUpperInvariant(); + var inferred = (descriptor.Cdc ?? string.Empty).Trim().ToUpperInvariant(); + var status = descriptor.StatusReference ?? string.Empty; + var mmsType = (descriptor.CtlValSpecification.MmsType ?? string.Empty).Trim().ToLowerInvariant(); + var size = descriptor.CtlValSpecification.Size; + var cdc = string.IsNullOrWhiteSpace(declared) ? inferred : declared; + + Iec61850ControlSemanticResolution Deny(string reason) + => new(false, cdc, reason); + + if (!descriptor.IsOperationallyReady) + return Deny("The live control descriptor or ctlModel is not operationally ready."); + if (string.IsNullOrWhiteSpace(status)) + return Deny("No live status reference corroborates the control semantics."); + + // A stale persisted feedback reference must not silently override a + // different live BSC status object when commands are involved. + if (cdc == "BSC" && !string.IsNullOrWhiteSpace(declaredStatusReference) && + !string.Equals(declaredStatusReference.Trim(), status, StringComparison.OrdinalIgnoreCase)) + return Deny("Declared BSC feedback reference disagrees with the live control descriptor."); + + if (cdc != inferred && + !(inferred == "INC/ISC" && cdc is "INC" or "ISC") && + !(cdc == "DPC" && inferred == "SPC" && IsStandardPosition(descriptor.ObjectReference))) + return Deny($"Declared CDC '{cdc}' conflicts with live CDC evidence '{inferred}'."); + + var statusIsStep = status.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase); + var statusIsState = status.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase); + var supported = cdc switch + { + "BSC" => statusIsStep && + (mmsType is "integer" or "unsigned" || + (mmsType == "bit-string" && size == 2)), + "DPC" => statusIsState && + ((mmsType == "bit-string" && size == 2) || + (mmsType == "boolean" && IsStandardPosition(descriptor.ObjectReference))), + "SPC" => statusIsState && mmsType == "boolean", + "INC" or "ISC" => declared == cdc && statusIsState && + mmsType is "integer" or "unsigned" or "bcd", + "APC" => declared == "APC" && + (mmsType == "floating-point" || IsAnalogStructure(descriptor.CtlValSpecification)), + _ => false // Other CDCs require an independently audited typed codec. + }; + + if (!supported) + return Deny($"CDC '{cdc}' cannot be safely bound to live ctlVal '{descriptor.CtlValSpecification.Signature}' and status '{status}'."); + + return new(true, cdc, + $"Verified CDC={cdc}; live ctlModel={descriptor.ControlModel}; ctlVal={descriptor.CtlValSpecification.Signature}; status={status}."); + } + + private static bool IsStandardPosition(string reference) + { + var suffix = reference[(reference.LastIndexOf('/') + 1)..]; + var dot = suffix.IndexOf('.'); + if (dot <= 0 || !suffix[(dot + 1)..].Equals("Pos", StringComparison.OrdinalIgnoreCase)) + return false; + var ln = suffix[..dot].ToUpperInvariant(); + return ln.StartsWith("CSWI", StringComparison.Ordinal) || + ln.StartsWith("XCBR", StringComparison.Ordinal) || + ln.StartsWith("XSWI", StringComparison.Ordinal); + } + + private static bool IsAnalogStructure(MmsTypeSpecificationNode ctlVal) + { + if (!ctlVal.MmsType.Equals("structure", StringComparison.OrdinalIgnoreCase)) + return false; + var numeric = ctlVal.Children.Where(child => + child.MmsType is "integer" or "unsigned" or "floating-point").ToArray(); + return numeric.Length == 1 && ctlVal.Children.Count == 1; + } +} + +public sealed record Iec61850ControlSemanticResolution(bool IsCommandSafe, string Cdc, string Reason); diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs index 59e36114..ee0d325d 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs @@ -8,6 +8,14 @@ namespace AR.Iec61850.Mms; public sealed partial class MmsClientSession { private readonly object _smartDiscoverySingleFlightSync = new(); + // Reuse a complete legacy control directory once per association when the + // smart-discovery inventory is not present (notably trusted Open SCL). + // This is structural metadata only; ctlModel and Oper/SBOw types stay live. + private Task>>? _controlNameInventoryTask; + private object? _controlNameInventoryAssociationMarker; + private string _controlNameInventoryHost = string.Empty; + private int _controlNameInventoryPort; + private object? _smartDiscoverySingleFlightAssociationMarker; private string _smartDiscoverySingleFlightHost = string.Empty; private int _smartDiscoverySingleFlightPort; @@ -102,7 +110,52 @@ internal async Task>> GetAutho } } - return await DiscoverDomainVariableNamesAsync(cancellationToken).ConfigureAwait(false); + return await GetLegacyControlInventoryOnceAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task>> GetLegacyControlInventoryOnceAsync( + CancellationToken cancellationToken) + { + var marker = LastAssociationAttempts; + var host = _lastHost; + var port = _lastPort; + Task>> pending; + lock (_smartDiscoverySingleFlightSync) + { + if (!ReferenceEquals(_controlNameInventoryAssociationMarker, marker) || + !string.Equals(_controlNameInventoryHost, host, StringComparison.OrdinalIgnoreCase) || + _controlNameInventoryPort != port) + { + _controlNameInventoryTask = null; + _controlNameInventoryAssociationMarker = marker; + _controlNameInventoryHost = host; + _controlNameInventoryPort = port; + } + pending = _controlNameInventoryTask ??= DiscoverDomainVariableNamesAsync(CancellationToken.None); + } + try + { + var result = await pending.WaitAsync(cancellationToken).ConfigureAwait(false); + // Never preserve empty/partial inventory as evidence after a failed + // enumeration; the next inspection can retry on the live association. + if (result.Count == 0 || result.Values.Any(names => names.Count == 0)) + ClearFailedControlInventory(pending); + return result; + } + catch + { + ClearFailedControlInventory(pending); + throw; + } + } + + private void ClearFailedControlInventory(Task>> task) + { + lock (_smartDiscoverySingleFlightSync) + { + if (ReferenceEquals(_controlNameInventoryTask, task)) + _controlNameInventoryTask = null; + } } private void ObserveSmartDiscoverySingleFlightCompletion(Task task) diff --git a/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs b/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs new file mode 100644 index 00000000..aa5bb41b --- /dev/null +++ b/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs @@ -0,0 +1,66 @@ +using AR.Iec61850.Control; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Control; + +public sealed class MultiVendorControlSemanticResolverTests +{ + [Theory] + [InlineData("BSC", "BSC", "bit-string", 2, "LD/ATCC1.TapChg.valWTr.posVal", true)] + [InlineData("DPC", "DPC", "bit-string", 2, "LD/CSWI1.Pos.stVal", true)] + [InlineData("SPC", "SPC", "boolean", 0, "LD/GGIO1.Enable.stVal", true)] + [InlineData("BSC", "DPC", "bit-string", 2, "LD/ATCC1.TapChg.stVal", false)] + [InlineData("DPC", "BSC", "bit-string", 2, "LD/CSWI1.Pos.valWTr.posVal", false)] + [InlineData("INC", "INC/ISC", "integer", 0, "LD/GGIO1.Set.stVal", true)] + [InlineData("", "INC/ISC", "integer", 0, "LD/GGIO1.Set.stVal", false)] + [InlineData("ISC", "INC/ISC", "bit-string", 2, "LD/ATCC1.Step.stVal", false)] + [InlineData("", "DPC", "bit-string", 2, "LD/CSWI1.Pos.stVal", true)] + public void ResolvesOnlyCorroboratedCommandSemantics( + string declared, string observed, string mmsType, int size, string status, bool safe) + { + var reference = status[..status.LastIndexOf('.')]; + if (status.EndsWith(".valWTr.posVal", StringComparison.OrdinalIgnoreCase)) + reference = reference[..reference.LastIndexOf('.')]; + var descriptor = NewDescriptor(reference, observed, mmsType, size, status); + var result = Iec61850ControlSemanticResolver.Resolve(descriptor, declared); + Assert.Equal(safe, result.IsCommandSafe); + Assert.False(string.IsNullOrWhiteSpace(result.Reason)); + } + + [Fact] + public void DisagreedPersistedBscFeedbackIsNotCommandAuthority() + { + var descriptor = NewDescriptor("LD/ATCC1.TapChg", "BSC", "bit-string", 2, + "LD/ATCC1.TapChg.valWTr.posVal"); + var result = Iec61850ControlSemanticResolver.Resolve( + descriptor, "BSC", "LD/ATCC2.TapChg.valWTr.posVal"); + Assert.False(result.IsCommandSafe); + Assert.Contains("disagrees", result.Reason); + } + + [Fact] + public void RawTwoBitEncodingAloneCannotEstablishBscDirection() + { + var descriptor = NewDescriptor("LD/ATCC1.TapChg", "DPC", "bit-string", 2, + "LD/ATCC1.TapChg.stVal"); + Assert.False(Iec61850ControlSemanticResolver.Resolve(descriptor, "BSC").IsCommandSafe); + } + + private static Iec61850ControlObjectDescriptor NewDescriptor( + string reference, string cdc, string type, int size, string status) => new() + { + ObjectReference = reference, + Cdc = cdc, + ControlModel = Iec61850ControlModel.DirectNormal, + CtlValSpecification = new MmsTypeSpecificationNode + { + Name = "ctlVal", MmsType = type, Size = size <= 0 ? null : size + }, + StatusReference = status, + OperSpecification = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = new[] { new MmsTypeSpecificationNode { Name = "ctlVal", MmsType = type } } + } + }; +} From 55fa34cef9a4abee589b9a6d94be1d0361375d71 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 17:46:11 +0700 Subject: [PATCH 109/176] P10.9L: add bounded read-only exact MMS ctlModel evidence probe --- .../Control/Iec61850ControlService.cs | 67 +++++++++++++++++++ .../Control/SmartControlStackTests.cs | 32 +++++++++ 2 files changed, 99 insertions(+) diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index 6b6aba9b..dccd5b18 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -41,6 +41,60 @@ public Task OpenAsync( cancellationToken); } + /// + /// Read-only exact CF ctlModel probe. An unsuccessful read, unknown encoding, + /// and an explicitly reported StatusOnly model are distinct outcomes. + /// Never discovers variable directories or writes any control service. + /// + public Task ProbeControlModelAsync( + MmsClientSession session, + string objectReference, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + return ProbeControlModelCoreAsync(new MmsClientControlTransport(session), objectReference, cancellationToken); + } + + internal async Task ProbeControlModelCoreAsync( + IIec61850ControlTransport transport, + string objectReference, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(transport); + if (!transport.IsAssociated) + throw new InvalidOperationException("IEC 61850 ctlModel probe requires an active MMS association."); + + var references = Iec61850ControlObjectReferences.Parse(objectReference); + var read = await transport.ReadAsync(references.CtlModel, cancellationToken).ConfigureAwait(false); + var kind = read.Value?.Kind.ToString() ?? "Unavailable"; + var numeric = read.Value?.Kind is MmsDataKind.Integer or MmsDataKind.Unsigned + ? Convert.ToString(read.Value.Value, System.Globalization.CultureInfo.InvariantCulture) ?? "" + : ""; + var model = read.IsSuccess && read.Value != null + ? DecodeControlModel(read.Value) + : Iec61850ControlModel.Unknown; + var decoded = read.IsSuccess && read.Value != null && model != Iec61850ControlModel.Unknown; + var detail = !read.IsSuccess || read.Value == null + ? $"MMS Read failed/unavailable: {read.Message}" + : !decoded + ? $"MMS Read returned unsupported ctlModel encoding/value ({kind}, {numeric})." + : $"Exact MMS Read returned ctlModel={numeric} ({model})."; + return new Iec61850ControlModelProbe( + references.ObjectReference, + references.CtlModel.ToString(), + read.IsSuccess && read.Value != null, + decoded, + model, + kind, + numeric, + detail, + BoundedHex(transport.LastRequestHex), + BoundedHex(transport.LastResponseHex)); + } + + private static string BoundedHex(string? hex) + => string.IsNullOrWhiteSpace(hex) ? "" : hex.Length <= 512 ? hex : hex[..512] + "… (truncated)"; + internal Task OpenCoreAsync( IIec61850ControlTransport transport, string objectReference, @@ -264,3 +318,16 @@ private static string InferCdc(MmsTypeSpecificationNode ctlVal) private static string NormalizeName(string value) => new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); } + +\n/// Read-only ctlModel evidence; not permission to send an MMS control write. +public sealed record Iec61850ControlModelProbe( + string ObjectReference, + string MmsReference, + bool ReadSucceeded, + bool Decoded, + Iec61850ControlModel ControlModel, + string RawKind, + string RawScalar, + string Detail, + string RequestHexPreview, + string ResponseHexPreview); diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index 4e4cb6f9..3427807d 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -579,6 +579,38 @@ public async Task GenericMmsWrite_BlocksControlServiceMembers() Assert.Contains("IIec61850ControlService", result.Message, StringComparison.Ordinal); } + [Theory] + [InlineData(0L, Iec61850ControlModel.StatusOnly)] + [InlineData(3L, Iec61850ControlModel.DirectEnhanced)] + [InlineData(4L, Iec61850ControlModel.SelectBeforeOperateEnhanced)] + public async Task ReadOnlyCtlModelProbe_DecodesExactReference_NoCommandWrites( + long raw, Iec61850ControlModel expected) + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(raw) }); + var probe = await new Iec61850ControlService().ProbeControlModelCoreAsync( + transport, "ZIVAVRLD1/ATCC1.TapChg"); + Assert.True(probe.ReadSucceeded); + Assert.True(probe.Decoded); + Assert.Equal(expected, probe.ControlModel); + Assert.Equal("ATCC1$CF$TapChg$ctlModel", Assert.Single(transport.Reads).Item); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task ReadOnlyCtlModelProbe_FailedReadCannotMasqueradeAsStatusOnly() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = false, Message = "object-non-existent" }); + var probe = await new Iec61850ControlService().ProbeControlModelCoreAsync( + transport, "ZIVAVRLD1/CSWI1.Pos"); + Assert.False(probe.ReadSucceeded); + Assert.False(probe.Decoded); + Assert.Equal(Iec61850ControlModel.Unknown, probe.ControlModel); + Assert.Contains("object-non-existent", probe.Detail); + Assert.Empty(transport.Writes); + } + [Fact] public async Task DescriptorDiscovery_UsesLiveCtlModelAndExactCtlValType() { From 5e17da5f673eedce125b5c66e7dc2fa0abef2851 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 17:56:23 +0700 Subject: [PATCH 110/176] fix: remove escaped newline from P10.9L probe declaration --- src/AR.Iec61850/Control/Iec61850ControlService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index dccd5b18..18e1b78b 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -319,7 +319,8 @@ private static string NormalizeName(string value) => new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); } -\n/// Read-only ctlModel evidence; not permission to send an MMS control write. + +/// Read-only ctlModel evidence; not permission to send an MMS control write. public sealed record Iec61850ControlModelProbe( string ObjectReference, string MmsReference, From f7a4e3ba2f9771e34e04073c0db0900112aa55fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 20:42:34 +0700 Subject: [PATCH 111/176] P10.9Q: explicit guarded one-shot CF ctlModel commissioning with exact readback --- .../Control/Iec61850ControlService.cs | 78 +++++++++++++++++++ .../Control/SmartControlStackTests.cs | 51 ++++++++++++ 2 files changed, 129 insertions(+) diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index 18e1b78b..34e72764 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -92,6 +92,74 @@ internal async Task ProbeControlModelCoreAsync( BoundedHex(transport.LastResponseHex)); } + /// + /// Explicit commissioning-only ctlModel update. The caller must confirm + /// device authorization separately. One exact CF write at most; no fallback + /// sequence or implicit command Operate is attempted. + /// + public Task ConfigureControlModelAsync( + MmsClientSession session, string objectReference, + Iec61850ControlModel expectedLiveModel, Iec61850ControlModel requestedModel, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + return ConfigureControlModelCoreAsync( + new MmsClientControlTransport(session), objectReference, + expectedLiveModel, requestedModel, cancellationToken); + } + + internal async Task ConfigureControlModelCoreAsync( + IIec61850ControlTransport transport, string objectReference, + Iec61850ControlModel expectedLiveModel, Iec61850ControlModel requestedModel, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(transport); + if (!transport.IsAssociated) + throw new InvalidOperationException("Control model configuration requires an active MMS association."); + if (expectedLiveModel == Iec61850ControlModel.Unknown || + requestedModel is not (Iec61850ControlModel.DirectNormal or + Iec61850ControlModel.SelectBeforeOperateNormal or + Iec61850ControlModel.DirectEnhanced or + Iec61850ControlModel.SelectBeforeOperateEnhanced)) + throw new ArgumentOutOfRangeException(nameof(requestedModel), + "Only exact, known control models 1-4 can be commissioned."); + + var reference = Iec61850ControlObjectReferences.Parse(objectReference).CtlModel; + var before = await ProbeControlModelCoreAsync( + transport, objectReference, cancellationToken).ConfigureAwait(false); + if (!before.Decoded || before.ControlModel != expectedLiveModel) + return new Iec61850ControlModelConfigurationResult( + false, false, before.ControlModel, Iec61850ControlModel.Unknown, + reference.ToString(), + $"No Write: live ctlModel changed or could not be verified. Expected={expectedLiveModel}; {before.Detail}"); + + if (expectedLiveModel == requestedModel) + return new Iec61850ControlModelConfigurationResult( + false, true, before.ControlModel, before.ControlModel, + reference.ToString(), "No Write: requested ctlModel is already active."); + + // Deliberate one-shot configuration: no retries, no auto-rollback. + var write = await transport.WriteControlAsync( + reference, MmsDataValue.Integer((long)requestedModel), cancellationToken) + .ConfigureAwait(false); + var writeAccepted = write.IsSuccess && write.AccessResults.All(x => x.IsSuccess); + if (!writeAccepted) + return new Iec61850ControlModelConfigurationResult( + false, false, before.ControlModel, Iec61850ControlModel.Unknown, + reference.ToString(), + $"MMS ctlModel Write rejected; readback not assumed. {write.Message}"); + + var after = await ProbeControlModelCoreAsync( + transport, objectReference, cancellationToken).ConfigureAwait(false); + var verified = after.Decoded && after.ControlModel == requestedModel; + return new Iec61850ControlModelConfigurationResult( + true, verified, before.ControlModel, after.ControlModel, + reference.ToString(), + verified + ? $"ctlModel Write accepted and exact MMS readback confirmed {requestedModel}." + : $"Write accepted but readback is not verified: {after.Detail}. Do not Operate until reinspection."); + } + private static string BoundedHex(string? hex) => string.IsNullOrWhiteSpace(hex) ? "" : hex.Length <= 512 ? hex : hex[..512] + "… (truncated)"; @@ -332,3 +400,13 @@ public sealed record Iec61850ControlModelProbe( string Detail, string RequestHexPreview, string ResponseHexPreview); + + +/// Configuration result is not permission to operate primary equipment. +public sealed record Iec61850ControlModelConfigurationResult( + bool WriteAccepted, + bool ReadbackVerified, + Iec61850ControlModel BeforeModel, + Iec61850ControlModel AfterModel, + string MmsReference, + string Detail); diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index 3427807d..a82370b4 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -579,6 +579,57 @@ public async Task GenericMmsWrite_BlocksControlServiceMembers() Assert.Contains("IIec61850ControlService", result.Message, StringComparison.Ordinal); } + [Fact] + public async Task CtlModelCommissioning_WritesExactCfOnce_AndVerifiesReadback() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(0) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); + var result = await new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "ZIVAVRLD1/CSWI1.Pos", + Iec61850ControlModel.StatusOnly, Iec61850ControlModel.SelectBeforeOperateNormal); + Assert.True(result.WriteAccepted); + Assert.True(result.ReadbackVerified); + var write = Assert.Single(transport.Writes); + Assert.Equal("CSWI1$CF$Pos$ctlModel", write.Reference.Item); + Assert.Equal(MmsDataKind.Integer, write.Value.Kind); + Assert.Equal(2L, write.Value.Value); + Assert.Equal(2, transport.Reads.Count); + Assert.DoesNotContain(transport.Writes, x => x.Reference.Item.Contains("$CO$", StringComparison.Ordinal)); + } + + [Fact] + public async Task CtlModelCommissioning_StaleExpectedValueNeverWrites() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(3) }); + var result = await new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "ZIVAVRLD1/CSWI2.Pos", + Iec61850ControlModel.StatusOnly, Iec61850ControlModel.SelectBeforeOperateEnhanced); + Assert.False(result.WriteAccepted); + Assert.False(result.ReadbackVerified); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task CtlModelCommissioning_DoesNotWriteUnknownOrNoChange() + { + var transport = new FakeControlTransport(); + await Assert.ThrowsAsync(() => + new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "LD0/CSWI1.Pos", + Iec61850ControlModel.StatusOnly, Iec61850ControlModel.Unknown)); + Assert.Empty(transport.Reads); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); + var result = await new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "LD0/CSWI1.Pos", + Iec61850ControlModel.SelectBeforeOperateNormal, + Iec61850ControlModel.SelectBeforeOperateNormal); + Assert.False(result.WriteAccepted); + Assert.True(result.ReadbackVerified); + Assert.Empty(transport.Writes); + } + [Theory] [InlineData(0L, Iec61850ControlModel.StatusOnly)] [InlineData(3L, Iec61850ControlModel.DirectEnhanced)] From d1dd8fc14e79c5de15fbc52d77aea48a14c7c02b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 13 Oct 2026 04:33:50 +0700 Subject: [PATCH 112/176] P10.9R: support explicit ctlModel status-only value 0 in one-shot CF write --- .../Control/Iec61850ControlService.cs | 4 ++-- .../Control/SmartControlStackTests.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index 34e72764..cd335559 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -117,12 +117,12 @@ internal async Task ConfigureControlMod if (!transport.IsAssociated) throw new InvalidOperationException("Control model configuration requires an active MMS association."); if (expectedLiveModel == Iec61850ControlModel.Unknown || - requestedModel is not (Iec61850ControlModel.DirectNormal or + requestedModel is not (Iec61850ControlModel.StatusOnly or Iec61850ControlModel.DirectNormal or Iec61850ControlModel.SelectBeforeOperateNormal or Iec61850ControlModel.DirectEnhanced or Iec61850ControlModel.SelectBeforeOperateEnhanced)) throw new ArgumentOutOfRangeException(nameof(requestedModel), - "Only exact, known control models 1-4 can be commissioned."); + "Only exact, known control models 0-4 can be commissioned."); var reference = Iec61850ControlObjectReferences.Parse(objectReference).CtlModel; var before = await ProbeControlModelCoreAsync( diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index a82370b4..aa004d12 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -611,6 +611,23 @@ public async Task CtlModelCommissioning_StaleExpectedValueNeverWrites() Assert.Empty(transport.Writes); } + [Fact] + public async Task CtlModelCommissioning_AllowExplicitStatusOnlyZero_WithReadback() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(0) }); + var result = await new Iec61850ControlService().ConfigureControlModelCoreAsync( + transport, "ZIVAVRLD1/CSWI1.Pos", + Iec61850ControlModel.SelectBeforeOperateNormal, + Iec61850ControlModel.StatusOnly); + Assert.True(result.WriteAccepted); + Assert.True(result.ReadbackVerified); + var write = Assert.Single(transport.Writes); + Assert.Equal("CSWI1$CF$Pos$ctlModel", write.Reference.Item); + Assert.Equal(0L, write.Value.Value); + } + [Fact] public async Task CtlModelCommissioning_DoesNotWriteUnknownOrNoChange() { From f3860d9d5be6d889d2d281b8b0e1f3e43cd62f97 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 13 Oct 2026 06:10:30 +0700 Subject: [PATCH 113/176] P10.9T: verify structured MMS status; allow normal SBO without optional Cancel --- .../Control/Iec61850ControlModels.cs | 4 +- .../Control/Iec61850ControlService.cs | 79 ++++++++++++++++++- .../Control/SmartControlStackTests.cs | 41 ++++++++-- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlModels.cs b/src/AR.Iec61850/Control/Iec61850ControlModels.cs index 7396a139..715d2742 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlModels.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlModels.cs @@ -174,7 +174,9 @@ public sealed class Iec61850ControlObjectDescriptor ControlModel is not Iec61850ControlModel.StatusOnly and not Iec61850ControlModel.Unknown && !string.IsNullOrWhiteSpace(ObjectReference) && !string.IsNullOrWhiteSpace(CtlValSpecification.MmsType) && - (!RequiresSelect || CancelSpecification != null) && + // Normal SBO Select -> Operate does not depend on optional Cancel. + // Enhanced SBO still requires Cancel for sequence recovery. + (ControlModel != Iec61850ControlModel.SelectBeforeOperateEnhanced || CancelSpecification != null) && (ControlModel != Iec61850ControlModel.SelectBeforeOperateEnhanced || SelectWithValueSpecification != null) && (!IsEnhanced || SupportsCommandTermination); diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index cd335559..34c48fb5 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -243,7 +243,7 @@ internal async Task OpenCoreAsync( _options.DefaultOperateTimeout, cancellationToken).ConfigureAwait(false); - var status = FindStatusReference(references, domainNames); + var status = await FindStatusReferenceAsync(transport, references, domainNames, cancellationToken).ConfigureAwait(false); var supportsTimeActivated = operSpecification.Children.Any(x => NormalizeName(x.Name) == "opertm"); var descriptor = new Iec61850ControlObjectDescriptor { @@ -367,6 +367,83 @@ private static (string Reference, string FunctionalConstraint) FindStatusReferen return ($"{references.Domain}/{parts[0]}.{string.Join(".", parts.Skip(2))}", parts[1]); } + // A structured ST DataObject can be listed without its scalar leaves. + // Corroborate status through a typed MMS read, not a guessed command type. + private static async Task<(string Reference, string FunctionalConstraint)> FindStatusReferenceAsync( + IIec61850ControlTransport transport, Iec61850ControlObjectReferences references, + IReadOnlyList names, CancellationToken cancellationToken) + { + var exact = FindStatusReference(references, names); + if (!string.IsNullOrWhiteSpace(exact.Reference)) + return exact; + var root = references.LogicalNode + "$ST$" + references.MmsDataObjectPath; + if (!names.Any(name => name.Equals(root, StringComparison.OrdinalIgnoreCase) || + name.StartsWith(root + "$", StringComparison.OrdinalIgnoreCase))) + return exact; + var candidates = new[] + { + (Suffix: "$valWTr$posVal", Path: ".valWTr.posVal", IsStep: true), + (Suffix: "$stVal", Path: ".stVal", IsStep: false) + }; + foreach (var candidate in candidates) + { + var item = new MmsObjectReference(references.Domain, root + candidate.Suffix, "ST"); + var read = await transport.ReadAsync(item, cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value == null) + continue; + var valid = candidate.IsStep + ? read.Value.Kind is MmsDataKind.Integer or MmsDataKind.Unsigned + : read.Value.Kind is MmsDataKind.BitString or MmsDataKind.Boolean; + if (valid) + return (references.Domain + "/" + references.LogicalNode + "." + + references.MmsDataObjectPath.Replace(' + { + var type = ctlVal.MmsType.Trim().ToLowerInvariant(); + if (type == "boolean") return "SPC"; + if (type == "bit-string") return "DPC"; + if (type == "floating-point") return "APC"; + if (type is "integer" or "unsigned" or "bcd") return "INC/ISC"; + if (type == "structure") + { + var names = ctlVal.Children.Select(x => NormalizeName(x.Name)).ToArray(); + if (names.Contains("posval") || names.Contains("transind")) return "BSC"; + if (names.Contains("f") || names.Contains("i")) return "APC"; + } + return "vendor-specific"; + } + + private static string NormalizeName(string value) + => new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); +} + + +/// Read-only ctlModel evidence; not permission to send an MMS control write. +public sealed record Iec61850ControlModelProbe( + string ObjectReference, + string MmsReference, + bool ReadSucceeded, + bool Decoded, + Iec61850ControlModel ControlModel, + string RawKind, + string RawScalar, + string Detail, + string RequestHexPreview, + string ResponseHexPreview); + + +/// Configuration result is not permission to operate primary equipment. +public sealed record Iec61850ControlModelConfigurationResult( + bool WriteAccepted, + bool ReadbackVerified, + Iec61850ControlModel BeforeModel, + Iec61850ControlModel AfterModel, + string MmsReference, + string Detail); +, '.') + candidate.Path, "ST"); + } + return exact; + } + private static string InferCdc(MmsTypeSpecificationNode ctlVal) { var type = ctlVal.MmsType.Trim().ToLowerInvariant(); diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index aa004d12..3763deb1 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -724,7 +724,38 @@ public async Task DescriptorDiscovery_RecognizesBscStepControl_FromExactLiveValW } [Fact] - public async Task DescriptorDiscovery_RejectsSboObjectWithoutCancelService() + public async Task StructuredStatusInventory_VerifiesScalarViaLiveRead() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = false, Message = "step absent" }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.BitString(6, new byte[] { 0x40 }) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] { "CSWI1$ST$Pos" }; + await using var session = await new Iec61850ControlService().OpenCoreAsync(transport, "LD0/CSWI1.Pos"); + Assert.Equal("LD0/CSWI1.Pos.stVal", session.Descriptor.StatusReference); + Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); + Assert.True(session.Descriptor.IsOperationallyReady); + Assert.Contains(transport.Reads, x => x.Item == "CSWI1$ST$Pos$stVal"); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task StructuredStatusInventory_UnreadableStatusNeverAuthorizesCommand() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecification(); + transport.DomainVariables["LD0"] = new[] { "CSWI1$ST$Pos" }; + await using var session = await new Iec61850ControlService().OpenCoreAsync(transport, "LD0/CSWI1.Pos"); + Assert.Equal("", session.Descriptor.StatusReference); + Assert.False(Iec61850ControlSemanticResolver.Resolve(session.Descriptor, "DPC").IsCommandSafe); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task DescriptorDiscovery_AcceptsSboNormalWithoutCancelService() { var transport = new FakeControlTransport(); transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); @@ -732,10 +763,10 @@ public async Task DescriptorDiscovery_RejectsSboObjectWithoutCancelService() transport.DomainVariables["LD0"] = new[] { "CSWI1$ST$Pos$stVal" }; var service = new Iec61850ControlService(); - var error = await Assert.ThrowsAsync(() => - service.OpenCoreAsync(transport, "LD0/CSWI1.Pos")); - - Assert.Contains("incomplete", error.Message, StringComparison.OrdinalIgnoreCase); + await using var session = await service.OpenCoreAsync(transport, "LD0/CSWI1.Pos"); + Assert.Equal(Iec61850ControlModel.SelectBeforeOperateNormal, session.Descriptor.ControlModel); + Assert.True(session.Descriptor.IsOperationallyReady); + Assert.Null(session.Descriptor.CancelSpecification); } private static Iec61850ControlObjectSession NewSession( From 9d82523978c29dcbf38f60cbfb384c3b050eae7c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 13 Oct 2026 06:13:19 +0700 Subject: [PATCH 114/176] P10.9T: repair structured-status implementation syntax and keep typed read-only probe --- .../Control/Iec61850ControlService.cs | 50 ++----------------- 1 file changed, 4 insertions(+), 46 deletions(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index 34c48fb5..03ddac93 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -367,8 +367,9 @@ private static (string Reference, string FunctionalConstraint) FindStatusReferen return ($"{references.Domain}/{parts[0]}.{string.Join(".", parts.Skip(2))}", parts[1]); } - // A structured ST DataObject can be listed without its scalar leaves. - // Corroborate status through a typed MMS read, not a guessed command type. + // An MMS NameList can contain a structured ST DataObject without all + // scalar descendants. Resolve a standard status only with a successful + // typed MMS Read. This remains read-only; it never authorizes Operate alone. private static async Task<(string Reference, string FunctionalConstraint)> FindStatusReferenceAsync( IIec61850ControlTransport transport, Iec61850ControlObjectReferences references, IReadOnlyList names, CancellationToken cancellationToken) @@ -396,50 +397,7 @@ private static (string Reference, string FunctionalConstraint) FindStatusReferen : read.Value.Kind is MmsDataKind.BitString or MmsDataKind.Boolean; if (valid) return (references.Domain + "/" + references.LogicalNode + "." + - references.MmsDataObjectPath.Replace(' - { - var type = ctlVal.MmsType.Trim().ToLowerInvariant(); - if (type == "boolean") return "SPC"; - if (type == "bit-string") return "DPC"; - if (type == "floating-point") return "APC"; - if (type is "integer" or "unsigned" or "bcd") return "INC/ISC"; - if (type == "structure") - { - var names = ctlVal.Children.Select(x => NormalizeName(x.Name)).ToArray(); - if (names.Contains("posval") || names.Contains("transind")) return "BSC"; - if (names.Contains("f") || names.Contains("i")) return "APC"; - } - return "vendor-specific"; - } - - private static string NormalizeName(string value) - => new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); -} - - -/// Read-only ctlModel evidence; not permission to send an MMS control write. -public sealed record Iec61850ControlModelProbe( - string ObjectReference, - string MmsReference, - bool ReadSucceeded, - bool Decoded, - Iec61850ControlModel ControlModel, - string RawKind, - string RawScalar, - string Detail, - string RequestHexPreview, - string ResponseHexPreview); - - -/// Configuration result is not permission to operate primary equipment. -public sealed record Iec61850ControlModelConfigurationResult( - bool WriteAccepted, - bool ReadbackVerified, - Iec61850ControlModel BeforeModel, - Iec61850ControlModel AfterModel, - string MmsReference, - string Detail); -, '.') + candidate.Path, "ST"); + references.MmsDataObjectPath.Replace('$', '.') + candidate.Path, "ST"); } return exact; } From 0ae297c85885b7e0cb9bbd9e02334d6c4f8a1a12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 13 Oct 2026 06:41:27 +0700 Subject: [PATCH 115/176] P10.9U: resolve CSWI Pos ST status via single typed MMS read when discovery NameList is truncated --- .../Iec61850ControlSemanticResolver.cs | 2 +- .../Control/Iec61850ControlService.cs | 37 ++++++--- .../Control/SmartControlStackTests.cs | 77 +++++++++++++++++++ 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs index ed2ac090..13887076 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs @@ -66,7 +66,7 @@ Iec61850ControlSemanticResolution Deny(string reason) $"Verified CDC={cdc}; live ctlModel={descriptor.ControlModel}; ctlVal={descriptor.CtlValSpecification.Signature}; status={status}."); } - private static bool IsStandardPosition(string reference) + internal static bool IsStandardPosition(string reference) { var suffix = reference[(reference.LastIndexOf('/') + 1)..]; var dot = suffix.IndexOf('.'); diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index 03ddac93..1de10269 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -244,6 +244,9 @@ internal async Task OpenCoreAsync( cancellationToken).ConfigureAwait(false); var status = await FindStatusReferenceAsync(transport, references, domainNames, cancellationToken).ConfigureAwait(false); + evidence.Add(string.IsNullOrWhiteSpace(status.Reference) + ? "status=not-verified" + : "status=live-confirmed:" + status.Reference); var supportsTimeActivated = operSpecification.Children.Any(x => NormalizeName(x.Name) == "opertm"); var descriptor = new Iec61850ControlObjectDescriptor { @@ -378,23 +381,39 @@ private static (string Reference, string FunctionalConstraint) FindStatusReferen if (!string.IsNullOrWhiteSpace(exact.Reference)) return exact; var root = references.LogicalNode + "$ST$" + references.MmsDataObjectPath; - if (!names.Any(name => name.Equals(root, StringComparison.OrdinalIgnoreCase) || - name.StartsWith(root + "$", StringComparison.OrdinalIgnoreCase))) + var hasListedStatusRoot = names.Any(name => + name.Equals(root, StringComparison.OrdinalIgnoreCase) || + name.StartsWith(root + "$", StringComparison.OrdinalIgnoreCase)); + var standardPosition = Iec61850ControlSemanticResolver.IsStandardPosition(references.ObjectReference); + + // Some servers cap their MMS NamedVariable NameList (e.g. 6400 + // variables) before CSWI.Pos.stVal is enumerated. The absence of a + // NameList entry is NOT proof that the scalar does not exist. For + // standard CSWI/XCBR/XSWI.Pos only, attempt exactly one ST.stVal Read. + // Never guess an arbitrary object's control semantics or send Operate. + if (!hasListedStatusRoot && !standardPosition) return exact; - var candidates = new[] - { - (Suffix: "$valWTr$posVal", Path: ".valWTr.posVal", IsStep: true), - (Suffix: "$stVal", Path: ".stVal", IsStep: false) - }; + + var candidates = standardPosition + ? new[] { (Suffix: "$stVal", Path: ".stVal", IsStep: false) } + : new[] + { + (Suffix: "$valWTr$posVal", Path: ".valWTr.posVal", IsStep: true), + (Suffix: "$stVal", Path: ".stVal", IsStep: false) + }; foreach (var candidate in candidates) { var item = new MmsObjectReference(references.Domain, root + candidate.Suffix, "ST"); var read = await transport.ReadAsync(item, cancellationToken).ConfigureAwait(false); if (!read.IsSuccess || read.Value == null) continue; + var value = read.Value; + var isExactTwoBitStatus = value.Kind == MmsDataKind.BitString && + value.RawValue.Count == 2 && value.RawValue[0] == 6; var valid = candidate.IsStep - ? read.Value.Kind is MmsDataKind.Integer or MmsDataKind.Unsigned - : read.Value.Kind is MmsDataKind.BitString or MmsDataKind.Boolean; + ? value.Kind is MmsDataKind.Integer or MmsDataKind.Unsigned + : standardPosition ? isExactTwoBitStatus : + isExactTwoBitStatus || value.Kind == MmsDataKind.Boolean; if (valid) return (references.Domain + "/" + references.LogicalNode + "." + references.MmsDataObjectPath.Replace('$', '.') + candidate.Path, "ST"); diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index 3763deb1..e6d8214e 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -723,6 +723,74 @@ public async Task DescriptorDiscovery_RecognizesBscStepControl_FromExactLiveValW Assert.Equal("bit-string", session.Descriptor.CtlValSpecification.MmsType); } + [Fact] + public async Task TruncatedDomainNameList_StandardPosVerifiedByTargetedTypedRead() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(2) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.BitString(6, new byte[] { 0x80 }) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.Specifications["CSWI1$CO$Pos$Cancel"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; // 6400-item NameList cap omitted CSWI + await using var control = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/CSWI1.Pos"); + Assert.Equal("LD0/CSWI1.Pos.stVal", control.Descriptor.StatusReference); + Assert.Equal("ST", control.Descriptor.StatusFunctionalConstraint); + Assert.True(control.Descriptor.IsOperationallyReady); + Assert.Equal("SPC", control.Descriptor.Cdc); // wire ctlVal is boolean, not the process CDC + Assert.True(Iec61850ControlSemanticResolver.Resolve(control.Descriptor, "DPC").IsCommandSafe); + Assert.Contains(transport.Reads, x => x.Item == "CSWI1$ST$Pos$stVal"); + Assert.Single(transport.Reads.Where(x => x.Item.Contains("$ST$Pos$", StringComparison.Ordinal))); + Assert.Empty(transport.Writes); + } + + [Theory] + [InlineData(7, 0x80)] + [InlineData(5, 0x80)] + public async Task TruncatedDomainNameList_InvalidPosBitWidthMustNotAuthorizeCommand(byte unusedBits, byte payload) + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, + Value = MmsDataValue.BitString(unusedBits, new byte[] { payload }) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + await using var control = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/CSWI1.Pos"); + Assert.Equal("", control.Descriptor.StatusReference); + Assert.False(Iec61850ControlSemanticResolver.Resolve(control.Descriptor, "DPC").IsCommandSafe); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task TruncatedDomainNameList_UnreadablePosMustNotAuthorizeCommand() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + await using var control = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/CSWI1.Pos"); + Assert.Equal("", control.Descriptor.StatusReference); + Assert.False(Iec61850ControlSemanticResolver.Resolve(control.Descriptor, "DPC").IsCommandSafe); + Assert.Empty(transport.Writes); + } + + [Fact] + public async Task TruncatedDomainNameList_NonPositionNoBlindScalarProbe() + { + var transport = new FakeControlTransport(); + transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); + transport.Specifications["GGIO1$CO$SomeCtrl$Oper"] = CommandSpecificationWithBooleanCtlVal(); + transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; + await using var control = await new Iec61850ControlService().OpenCoreAsync( + transport, "LD0/GGIO1.SomeCtrl"); + Assert.Empty(transport.Reads.Where(x => x.FunctionalConstraint == "ST")); + Assert.Equal("", control.Descriptor.StatusReference); + Assert.Empty(transport.Writes); + } + [Fact] public async Task StructuredStatusInventory_VerifiesScalarViaLiveRead() { @@ -812,6 +880,15 @@ private static Iec61850ControlRequest Request(Iec61850ControlValue value, byte? InterlockCheck = true }; + private static MmsTypeSpecificationNode CommandSpecificationWithBooleanCtlVal() + => Structure(string.Empty, + Type("ctlVal", "boolean"), + Structure("origin", Type("orCat", "integer"), Type("orIdent", "octet-string", size: 64)), + Type("ctlNum", "unsigned"), + Type("T", "utc-time"), + Type("Test", "boolean"), + Type("Check", "bit-string", size: 2)); + private static MmsTypeSpecificationNode CommandSpecification() => Structure(string.Empty, Type("ctlVal", "bit-string", size: 2), From 1a7ce83315c2ba3da62fae53b8db58fb3bacb4de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 13 Oct 2026 06:50:55 +0700 Subject: [PATCH 116/176] P10.9U: fix xUnit analyzer violations in truncated-NameList control tests --- tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index e6d8214e..d1756469 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -741,7 +741,7 @@ public async Task TruncatedDomainNameList_StandardPosVerifiedByTargetedTypedRead Assert.Equal("SPC", control.Descriptor.Cdc); // wire ctlVal is boolean, not the process CDC Assert.True(Iec61850ControlSemanticResolver.Resolve(control.Descriptor, "DPC").IsCommandSafe); Assert.Contains(transport.Reads, x => x.Item == "CSWI1$ST$Pos$stVal"); - Assert.Single(transport.Reads.Where(x => x.Item.Contains("$ST$Pos$", StringComparison.Ordinal))); + Assert.Single(transport.Reads, x => x.Item.Contains("$ST$Pos$", StringComparison.Ordinal)); Assert.Empty(transport.Writes); } @@ -786,7 +786,7 @@ public async Task TruncatedDomainNameList_NonPositionNoBlindScalarProbe() transport.DomainVariables["LD0"] = new[] { "LLN0$ST$Beh$stVal" }; await using var control = await new Iec61850ControlService().OpenCoreAsync( transport, "LD0/GGIO1.SomeCtrl"); - Assert.Empty(transport.Reads.Where(x => x.FunctionalConstraint == "ST")); + Assert.DoesNotContain(transport.Reads, x => x.FunctionalConstraint == "ST"); Assert.Equal("", control.Descriptor.StatusReference); Assert.Empty(transport.Writes); } From ad43399ed411cc72e6eb68b67e198db5e7b95381 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 13 Oct 2026 06:54:10 +0700 Subject: [PATCH 117/176] P10.9U: align structured position regression with exactly one typed ST read --- tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs index d1756469..ea028b83 100644 --- a/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs +++ b/tests/AR.Iec61850.Tests/Control/SmartControlStackTests.cs @@ -796,7 +796,8 @@ public async Task StructuredStatusInventory_VerifiesScalarViaLiveRead() { var transport = new FakeControlTransport(); transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.Integer(1) }); - transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = false, Message = "step absent" }); + // CSWI.Pos requires exactly one typed ST.stVal read, not a + // speculative valWTr probe first. transport.ReadResults.Enqueue(new MmsReadResult { IsSuccess = true, Value = MmsDataValue.BitString(6, new byte[] { 0x40 }) }); transport.Specifications["CSWI1$CO$Pos$Oper"] = CommandSpecification(); From 38420f179a20c12775f1a57c00f663743d2e8f7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 13 Oct 2026 07:39:11 +0700 Subject: [PATCH 118/176] fix(control): honor exact typed live BSC over stale SPC projection --- .../Iec61850ControlSemanticResolver.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs index 13887076..8ed2c293 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlSemanticResolver.cs @@ -20,7 +20,19 @@ public static Iec61850ControlSemanticResolution Resolve( var status = descriptor.StatusReference ?? string.Empty; var mmsType = (descriptor.CtlValSpecification.MmsType ?? string.Empty).Trim().ToLowerInvariant(); var size = descriptor.CtlValSpecification.Size; - var cdc = string.IsNullOrWhiteSpace(declared) ? inferred : declared; + // A saved SCL/DataSet SPC can describe a process projection, not the + // command payload. Only an EXACT same-object ST step status plus a + // typed two-bit MMS ctlVal establishes live directional BSC authority. + // A bit-string alone (or a similarly named object) proves nothing. + var exactLiveBsc = + inferred == "BSC" && + mmsType == "bit-string" && size == 2 && + status.Equals(descriptor.ObjectReference + ".valWTr.posVal", + StringComparison.OrdinalIgnoreCase); + var supersededAdvisorySpc = declared == "SPC" && exactLiveBsc; + var cdc = supersededAdvisorySpc + ? "BSC" + : string.IsNullOrWhiteSpace(declared) ? inferred : declared; Iec61850ControlSemanticResolution Deny(string reason) => new(false, cdc, reason); @@ -62,8 +74,11 @@ Iec61850ControlSemanticResolution Deny(string reason) if (!supported) return Deny($"CDC '{cdc}' cannot be safely bound to live ctlVal '{descriptor.CtlValSpecification.Signature}' and status '{status}'."); + var provenance = supersededAdvisorySpc + ? " Saved/DataSet CDC=SPC was advisory and superseded by exact live BSC control evidence." + : string.Empty; return new(true, cdc, - $"Verified CDC={cdc}; live ctlModel={descriptor.ControlModel}; ctlVal={descriptor.CtlValSpecification.Signature}; status={status}."); + $"Verified CDC={cdc}; live ctlModel={descriptor.ControlModel}; ctlVal={descriptor.CtlValSpecification.Signature}; status={status}.{provenance}"); } internal static bool IsStandardPosition(string reference) From 036e2c00d83a0815b6160147b14ea05975057965 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 13 Oct 2026 07:39:31 +0700 Subject: [PATCH 119/176] test(control): cover stale SPC to exact BSC and fail-closed mismatches --- ...MultiVendorControlSemanticResolverTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs b/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs index aa5bb41b..9f71a25d 100644 --- a/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Control/MultiVendorControlSemanticResolverTests.cs @@ -27,6 +27,65 @@ public void ResolvesOnlyCorroboratedCommandSemantics( Assert.False(string.IsNullOrWhiteSpace(result.Reason)); } + [Fact] + public void VerifiedLiveTapBscSupersedesStaleProjectedSpc_WithoutBooleanFallback() + { + var reference = "LD/ATCC1.TapChg"; + var feedback = reference + ".valWTr.posVal"; + var descriptor = NewDescriptor(reference, "BSC", "bit-string", 2, feedback); + + // Reinspect/refresh after a static DataSet projected SPC. The + // descriptor is generated from the live MMS ctlVal + step status. + var first = Iec61850ControlSemanticResolver.Resolve(descriptor, "SPC", feedback); + Assert.True(first.IsCommandSafe); + Assert.Equal("BSC", first.Cdc); + Assert.Contains("advisory", first.Reason, StringComparison.OrdinalIgnoreCase); + + // Subsequent Operate validation uses the verified BSC authority. + var repeated = Iec61850ControlSemanticResolver.Resolve(descriptor, first.Cdc, feedback); + Assert.True(repeated.IsCommandSafe); + Assert.Equal("BSC", repeated.Cdc); + } + + [Theory] + [InlineData("bit-string", 1)] + [InlineData("bit-string", 3)] + [InlineData("boolean", 0)] + public void ProjectedSpcConflictCannotAuthorizeWrongLiveCtlVal(string wireType, int width) + { + var reference = "LD/ATCC1.TapChg"; + var descriptor = NewDescriptor(reference, "BSC", wireType, width, + reference + ".valWTr.posVal"); + var resolution = Iec61850ControlSemanticResolver.Resolve(descriptor, "SPC"); + Assert.False(resolution.IsCommandSafe); + Assert.NotEqual("BSC", resolution.Cdc); + } + + [Fact] + public void ProjectedSpcConflictCannotOverrideDifferentObjectOrPersistedFeedback() + { + var reference = "LD/ATCC1.TapChg"; + var exact = reference + ".valWTr.posVal"; + var differentObject = NewDescriptor(reference, "BSC", "bit-string", 2, + "LD/ATCC2.TapChg.valWTr.posVal"); + Assert.False(Iec61850ControlSemanticResolver.Resolve(differentObject, "SPC").IsCommandSafe); + + var live = NewDescriptor(reference, "BSC", "bit-string", 2, exact); + var conflict = Iec61850ControlSemanticResolver.Resolve( + live, "SPC", "LD/ATCC2.TapChg.valWTr.posVal"); + Assert.False(conflict.IsCommandSafe); + Assert.Contains("disagrees", conflict.Reason); + } + + [Fact] + public void UnrelatedDeclaredCdcDoesNotGainBscAuthority() + { + var descriptor = NewDescriptor("LD/ATCC1.TapChg", "BSC", "bit-string", 2, + "LD/ATCC1.TapChg.valWTr.posVal"); + var result = Iec61850ControlSemanticResolver.Resolve(descriptor, "DPC"); + Assert.False(result.IsCommandSafe); + } + [Fact] public void DisagreedPersistedBscFeedbackIsNotCommandAuthority() { From cfd2a54baf3dce700adb2ea78ec13b9321bd9509 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:00:08 +0700 Subject: [PATCH 120/176] 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 121/176] 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 122/176] 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 123/176] 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 b27842a950959028421c96119e3e62bd9fa5c519 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:52:33 +0700 Subject: [PATCH 124/176] test(identity): reproduce truncated device-name suffix and unverified confidence --- .../Discovery/LiveIedIdentityResolverTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 308799b6..3154cc88 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -6,6 +6,38 @@ namespace AR.Iec61850.Tests.Discovery; public sealed class LiveIedIdentityResolverTests { + + [Fact] + public void Resolve_Five_LogicalDevices_Preserves_Full_Common_Ied_Name_Without_Scl() + { + var domains = new[] { "SIEBCUCTRL", "SIEBCUMEAS", "SIEBCUPROT", "SIEBCUUD1", "SIEBCUUD2" }; + var identity = LiveIedIdentityResolver.Resolve(domains, "192.0.2.20"); + + Assert.Equal("SIEBCU", identity.IedName); + Assert.Equal("MmsDomainCommonPrefix", identity.Source); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, identity.Confidence); + Assert.Equal("CTRL", identity.LogicalDeviceAliases["SIEBCUCTRL"]); + Assert.Equal("UD2", identity.LogicalDeviceAliases["SIEBCUUD2"]); + } + + [Theory] + [InlineData("BCU")] + [InlineData("PROT")] + [InlineData("CTRL")] + [InlineData("MEAS")] + [InlineData("MU")] + public void Resolve_Common_Prefix_Does_Not_Remove_A_Valid_Ied_Name_Ending(string ending) + { + var fullName = "STATION" + ending; + var identity = LiveIedIdentityResolver.Resolve( + [fullName + "Application", fullName + "Reports", fullName + "Custom1"], + "192.0.2.21"); + + Assert.Equal(fullName, identity.IedName); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + } + [Fact] public void Resolve_Preserves_Digits_In_A_Single_Ld0_Domain() { From bcc8e228bc8d610ba3ffb786412a859e9fd57194 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:52:59 +0700 Subject: [PATCH 125/176] fix(identity): preserve exact shared prefix and mark unverified domain identity provisional --- .../Discovery/LiveIedIdentityResolver.cs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8508a176..8a6e2c01 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -89,9 +89,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", @@ -235,18 +233,10 @@ private static string InferCommonPrefix(IReadOnlyList domains) return string.Empty; } + // The common prefix is evidence of the shared IED-name candidate, not a + // logical-device suffix. Removing a known LD stem here corrupts valid + // names such as SIEBCU, STATIONPROT and STATIONMU. 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; } From e1ba425f6c7a94581c3bee1b5c0d7a025ada38e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:53:02 +0700 Subject: [PATCH 126/176] test(identity): align pre-existing shared-prefix expectation with provisional confidence --- .../AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 3154cc88..9812e9a4 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -77,7 +77,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); } From f2bf05ea6e27c7ae0c650162fc0c7bcf9870f077 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:00:24 +0700 Subject: [PATCH 127/176] test(identity): require all MMS-only and operator identities remain unverified --- .../Discovery/LiveIedIdentityResolverTests.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 9812e9a4..d90426db 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -57,7 +57,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"]); } @@ -101,7 +101,18 @@ 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); + Assert.Equal("ExplicitOverride", identity.Source); + } + + [Fact] + public void Heuristic_Suffix_Is_Never_A_Verified_Identity_Even_With_Five_Lds() + { + var identity = LiveIedIdentityResolver.Resolve( + ["PLANTCTRL", "PLANTMEAS", "PLANTPROT", "PLANTLD0", "PLANTMU1"], "192.0.2.90"); + Assert.Equal("PLANT", identity.IedName); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + Assert.DoesNotContain(identity.CandidateNames, name => name.Equals("PLANTCTRL", StringComparison.Ordinal)); } [Fact] @@ -113,7 +124,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 5b30f51fac5cbc34d20232573fc5b127cdf3b809 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:00:42 +0700 Subject: [PATCH 128/176] test(identity): require provisional confidence for suffix and operator alias --- .../Discovery/LiveIedIdentityResolverTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index d90426db..8d143c23 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -94,6 +94,28 @@ public void Resolve_Rejects_Conflicting_Domain_Candidates() Assert.Equal(["ALPHA", "BETA"], identity.CandidateNames); } + + [Fact] + public void Resolve_Fully_Known_Ld_Suffixes_Is_Still_Provisional_Without_Scl() + { + var identity = LiveIedIdentityResolver.Resolve( + ["STATIONBCUCTRL", "STATIONBCUPROT", "STATIONBCUMEAS"], + "192.0.2.20"); + Assert.Equal("STATIONBCU", identity.IedName); + Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + + [Fact] + public void Resolve_Explicit_Alias_Is_Operator_Supplied_Not_Wire_Verified() + { + var identity = LiveIedIdentityResolver.Resolve( + ["WIRELD0"], "192.0.2.20", explicitIedName: "OPERATOR_ALIAS"); + Assert.Equal("OPERATOR_ALIAS", identity.IedName); + Assert.Equal("ExplicitOverride", identity.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + [Fact] public void Resolve_Uses_Explicit_Name_Over_Live_Heuristic() { From fbd6c4f304335e1f69082e4f56e472e9bf5057a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:00:45 +0700 Subject: [PATCH 129/176] test(identity): require exact five-LD SCL and fail-closed AP/IP/domain evidence --- .../TrustedSclIedIdentityMatcherTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs index 62274a78..32f48d80 100644 --- a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -93,6 +93,49 @@ public void Ambiguous_Duplicate_AccessPoints_And_Conflicting_Sources_Are_Not_Pro model,unverified)); } + + [Fact] + public void Five_Domain_Bcu_Scl_Promotes_Exact_Identity_With_Logical_Device_Aliases() + { + var scl=Design("SIEBCU","CTRL","192.0.2.20"); + XNamespace ns=SclNs; + var server=Assert.Single(scl.Descendants(ns+"Server")); + foreach (var suffix in new[] {"MEAS","PROT","UD1","UD2"}) + server.Add(new XElement(ns+"LDevice",new XAttribute("inst",suffix), + new XElement(ns+"LN0",new XAttribute("lnType","LLN0_type")))); + var domains=new[] {"SIEBCUCTRL","SIEBCUMEAS","SIEBCUPROT","SIEBCUUD1","SIEBCUUD2"}; + var verified=TrustedSclIedIdentityMatcher.TryMatch( + scl,domains,"192.0.2.20",selectedIedName:"SIEBCU",selectedAccessPointName:"S1"); + Assert.NotNull(verified); + Assert.Equal("SIEBCU",verified.IedName); + Assert.Equal("TrustedSclExactDomainMatch",verified.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High,verified.Confidence); + Assert.Equal("UD2",verified.LogicalDeviceAliases["SIEBCUUD2"]); + Assert.Equal(5,verified.LogicalDeviceAliases.Count); + } + + [Fact] + public void Scl_Endpoint_Address_And_AccessPoint_Are_Mandatory_For_Verified_Identity() + { + XNamespace ns=SclNs; + var scl=Design("STATIONBCU","CTRL","192.0.2.30"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],"192.0.2.30",selectedAccessPointName:"S2")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],null)); + var noIp=new XDocument(scl); + Assert.Single(noIp.Descendants(ns+"P")).Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + noIp,["STATIONBCUCTRL"],"192.0.2.30")); + var noConnectedAp=new XDocument(scl); + Assert.Single(noConnectedAp.Descendants(ns+"ConnectedAP")).Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + noConnectedAp,["STATIONBCUCTRL"],"192.0.2.30")); + var wrongDomainCase=new[] {"stationBCUCTRL"}; + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,wrongDomainCase,"192.0.2.30")); + } + [Fact] public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged() { From 4cc3bde292ab43d6066fc41419dd32736b87cf49 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:00:53 +0700 Subject: [PATCH 130/176] test(identity): five-LD SCL proof and fail-closed missing endpoint --- .../TrustedSclIedIdentityMatcherTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs index 32f48d80..69a8a823 100644 --- a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -8,6 +8,39 @@ public sealed class TrustedSclIedIdentityMatcherTests { private const string SclNs = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void Five_Ld_Scl_Uniquely_Verifies_Full_Ied_Name_Not_A_Stripped_Prefix() + { + XNamespace ns = SclNs; + var scl = Design("SIEBCU", "CTRL", "192.0.2.55"); + var server = Assert.Single(scl.Descendants(ns + "Server")); + foreach (var inst in new[] { "MEAS", "PROT", "UD1", "UD2" }) + server.Add(new XElement(ns + "LDevice", new XAttribute("inst", inst))); + + var domains = new[] { "SIEBCUCTRL", "SIEBCUMEAS", "SIEBCUPROT", "SIEBCUUD1", "SIEBCUUD2" }; + var identity = TrustedSclIedIdentityMatcher.TryMatch(scl, domains, "192.0.2.55", "SIEBCU"); + + Assert.NotNull(identity); + Assert.Equal("SIEBCU", identity.IedName); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal("UD2", identity.LogicalDeviceAliases["SIEBCUUD2"]); + Assert.Equal(5, identity.LogicalDeviceAliases.Count); + } + + [Fact] + public void Engineering_Topology_Without_Unique_Confirmed_Endpoint_Cannot_Promote_Identity() + { + XNamespace ns = SclNs; + var scl = Design("SIEBCU", "CTRL", "192.0.2.55"); + scl.Root!.Element(ns + "Communication")!.Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(scl, ["SIEBCUCTRL"], "192.0.2.55")); + + var validScl = Design("SIEBCU", "CTRL", "192.0.2.55"); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(validScl, ["SIEBCUCTRL"], null)); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch(validScl, ["SIEBCUCTRL"], "not-an-ip")); + } + [Fact] public void GE_F650_Single_Domain_Uses_SCL_Evidence_Not_Vendor_Suffix_Heuristic() { From 10b3584d6c4ba048b80934619940a6bcfa42f653 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:01:04 +0700 Subject: [PATCH 131/176] fix(identity): do not mark operator aliases or MMS suffix heuristics as verified --- src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8a6e2c01..8c586748 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -38,8 +38,8 @@ public static LiveIedIdentity Resolve( if (!string.IsNullOrWhiteSpace(explicitIedName)) { var name = explicitIedName.Trim(); - return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Exact, false, [name], materialized, - [$"IED name was supplied explicitly as '{name}'."]); + return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Medium, false, [name], materialized, + [$"Operator-supplied IED display name '{name}' has not been independently verified against this MMS endpoint."]); } var suffixMatches = materialized @@ -56,7 +56,7 @@ public static LiveIedIdentity Resolve( .OrderBy(candidate => candidate, StringComparer.OrdinalIgnoreCase) .ToArray(); - // A known LD suffix is authoritative only when it explains the complete domain set. + // A known LD suffix is only a provisional naming hint even when it explains the complete domain set. // A single nested domain such as OCR7SJ8Mod2_MU1 must never rename the physical IED // to OCR7SJ8Mod2 while sibling domains prove the shared OCR7SJ8 prefix. if (materialized.Length > 0 && suffixMatches.Length == materialized.Length) @@ -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(); @@ -110,7 +108,7 @@ public static LiveIedIdentity Resolve( return Create( name, "MmsDomainKnownLogicalDeviceSuffix", - materialized.Length == 1 ? LiveIedDiscoveryConfidenceLevel.Medium : LiveIedDiscoveryConfidenceLevel.High, + LiveIedDiscoveryConfidenceLevel.Medium, false, distinctSuffixCandidates, materialized, From 1f3a601392f08117a059b9bec932d9c6ee45825c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:01:25 +0700 Subject: [PATCH 132/176] fix(identity): mark all MMS suffix inference and operator alias as provisional From f2bdb7f990fbb10fce9b5abeb2ca5a5cb2cdaeba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:01:28 +0700 Subject: [PATCH 133/176] fix(identity): require exact SCL domain/AP and connected endpoint proof --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index a3b8918a..86d3b1a7 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -17,7 +17,8 @@ public static class TrustedSclIedIdentityMatcher XDocument trustedScl, IEnumerable observedMmsDomains, string? connectedHost, - string? selectedIedName = null) + string? selectedIedName = null, + string? selectedAccessPointName = null) { ArgumentNullException.ThrowIfNull(trustedScl); ArgumentNullException.ThrowIfNull(observedMmsDomains); @@ -25,8 +26,8 @@ public static class TrustedSclIedIdentityMatcher var observed = observedMmsDomains .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + .Distinct(StringComparer.Ordinal) + .ToHashSet(StringComparer.Ordinal); if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); @@ -35,7 +36,7 @@ public static class TrustedSclIedIdentityMatcher var name = ((string?)ied.Attribute("name") ?? "").Trim(); if (string.IsNullOrWhiteSpace(name) || (!string.IsNullOrWhiteSpace(selectedIedName) && - !name.Equals(selectedIedName.Trim(), StringComparison.OrdinalIgnoreCase))) + !name.Equals(selectedIedName.Trim(), StringComparison.Ordinal))) continue; foreach (var ap in ied.Elements(Scl + "AccessPoint")) @@ -46,7 +47,7 @@ public static class TrustedSclIedIdentityMatcher ?? Array.Empty(); if (devices.Length != observed.Count) continue; - var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var aliases = new Dictionary(StringComparer.Ordinal); var invalid = false; foreach (var ld in devices) { @@ -68,30 +69,32 @@ public static class TrustedSclIedIdentityMatcher var connected = trustedScl.Root.Element(Scl + "Communication")? .Descendants(Scl + "ConnectedAP") - .Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.OrdinalIgnoreCase) - && string.Equals((string?)x.Attribute("apName"),apName,StringComparison.OrdinalIgnoreCase)) + .Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.Ordinal) + && string.Equals((string?)x.Attribute("apName"),apName,StringComparison.Ordinal)) .ToArray() ?? Array.Empty(); - if (connected.Length > 1) continue; - var ipValues = connected.SingleOrDefault()? - .Element(Scl + "Address")?.Elements(Scl + "P") + // SCL topology alone is not endpoint evidence. Require exactly one + // declared ConnectedAP/IP and an exact observed association endpoint. + // Missing/ambiguous endpoints remain provisional, never verified. + if (connected.Length != 1 || string.IsNullOrWhiteSpace(connectedHost)) + continue; + var ipValues = connected[0].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; - 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 - } - matches.Add((name, apName, aliases, ip)); + if (ipValues.Length != 1 || + !IPAddress.TryParse(ipValues[0],out var designIp) || + !IPAddress.TryParse(connectedHost.Trim(),out var observedIp) || + !designIp.Equals(observedIp)) + continue; + matches.Add((name, apName, aliases, ipValues[0])); } } // 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; var match=matches[0]; + if (!string.IsNullOrWhiteSpace(selectedAccessPointName) && + !string.Equals(match.Ap, selectedAccessPointName.Trim(), StringComparison.Ordinal)) + return null; return new LiveIedIdentity { IedName=match.Ied, From a662834be11227b1872df011551029fbc24e9e81 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:07:05 +0700 Subject: [PATCH 134/176] fix(identity): fail closed on topology-equivalent AP with missing endpoint evidence --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index 86d3b1a7..b7fd9424 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -31,6 +31,7 @@ public static class TrustedSclIedIdentityMatcher if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); + var ambiguousUnboundTopology = false; foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) { var name = ((string?)ied.Attribute("name") ?? "").Trim(); @@ -76,21 +77,28 @@ public static class TrustedSclIedIdentityMatcher // declared ConnectedAP/IP and an exact observed association endpoint. // Missing/ambiguous endpoints remain provisional, never verified. if (connected.Length != 1 || string.IsNullOrWhiteSpace(connectedHost)) + { + ambiguousUnboundTopology = true; continue; + } var ipValues = connected[0].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 || !IPAddress.TryParse(ipValues[0],out var designIp) || - !IPAddress.TryParse(connectedHost.Trim(),out var observedIp) || - !designIp.Equals(observedIp)) + !IPAddress.TryParse(connectedHost.Trim(),out var observedIp)) + { + ambiguousUnboundTopology = true; + continue; + } + if (!designIp.Equals(observedIp)) continue; matches.Add((name, apName, aliases, ipValues[0])); } } // 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 (matches.Count != 1 || ambiguousUnboundTopology) return null; var match=matches[0]; if (!string.IsNullOrWhiteSpace(selectedAccessPointName) && !string.Equals(match.Ap, selectedAccessPointName.Trim(), StringComparison.Ordinal)) From 9e6b142adf6b09da40ae7ffe520a9ccdf5f9a386 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:08:55 +0700 Subject: [PATCH 135/176] fix(identity): reject all duplicate topology-compatible APs before endpoint filtering --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index b7fd9424..51376afd 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -31,6 +31,7 @@ public static class TrustedSclIedIdentityMatcher if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); + var topologyMatches = 0; var ambiguousUnboundTopology = false; foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) { @@ -43,7 +44,10 @@ public static class TrustedSclIedIdentityMatcher foreach (var ap in ied.Elements(Scl + "AccessPoint")) { var apName = ((string?)ap.Attribute("name") ?? "").Trim(); - if (string.IsNullOrWhiteSpace(apName)) continue; + if (string.IsNullOrWhiteSpace(apName) || + (!string.IsNullOrWhiteSpace(selectedAccessPointName) && + !string.Equals(apName, selectedAccessPointName.Trim(), StringComparison.Ordinal))) + continue; var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray() ?? Array.Empty(); if (devices.Length != observed.Count) continue; @@ -67,6 +71,10 @@ public static class TrustedSclIedIdentityMatcher } } if (invalid || aliases.Count != observed.Count) continue; + // Count every topology-compatible AP before endpoint filtering. + // One AP lacking ConnectedAP is still a competing possibility + // when the operator has not selected a unique AP. + topologyMatches++; var connected = trustedScl.Root.Element(Scl + "Communication")? .Descendants(Scl + "ConnectedAP") From 2dbfc2152d19ed2aa893274c3b285178732a3159 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:11:02 +0700 Subject: [PATCH 136/176] test(identity): preserve distinct case-sensitive live MMS LD domain names --- .../Discovery/LiveIedIdentityResolverTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 8d143c23..1fbeaa46 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -38,6 +38,17 @@ public void Resolve_Common_Prefix_Does_Not_Remove_A_Valid_Ied_Name_Ending(string Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); } + + [Fact] + public void Mms_Domain_Names_Are_Case_Sensitive_And_Never_Coalesced() + { + var identity = LiveIedIdentityResolver.Resolve( + ["SIEBCUCTRL", "siebcuctrl", "SIEBCUMEAS"], "192.0.2.61"); + Assert.Equal(3, identity.LogicalDeviceAliases.Count); + Assert.Contains("SIEBCUCTRL", identity.LogicalDeviceAliases.Keys); + Assert.Contains("siebcuctrl", identity.LogicalDeviceAliases.Keys); + } + [Fact] public void Resolve_Preserves_Digits_In_A_Single_Ld0_Domain() { From 3c841f227d4cab35669e1f179d3a3f82d5c809e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:11:12 +0700 Subject: [PATCH 137/176] test(identity): AP selector cannot conceal another indistinguishable AP --- .../TrustedSclIedIdentityMatcherTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs index 69a8a823..6de86a25 100644 --- a/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/TrustedSclIedIdentityMatcherTests.cs @@ -169,6 +169,22 @@ public void Scl_Endpoint_Address_And_AccessPoint_Are_Mandatory_For_Verified_Iden scl,wrongDomainCase,"192.0.2.30")); } + + [Fact] + public void Selected_Ap_Name_Alone_Does_Not_Disambiguate_Identical_Unbound_Topology() + { + XNamespace ns=SclNs; + var scl=Design("STATIONBCU","CTRL","192.0.2.30"); + var ied=Assert.Single(scl.Descendants(ns+"IED")); + var ap=Assert.Single(ied.Elements(ns+"AccessPoint")); + var copy=new XElement(ap); + copy.SetAttributeValue("name","S2"); + ied.Add(copy); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl,["STATIONBCUCTRL"],"192.0.2.30", + selectedIedName:"STATIONBCU",selectedAccessPointName:"S1")); + } + [Fact] public void Other_Devices_And_Already_Recognized_Multiple_Domains_Are_Unchanged() { From a12cee613a40754d748010faf24c449b40fe5a7b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:11:15 +0700 Subject: [PATCH 138/176] fix(identity): evaluate all AP ambiguity before applying operator AP selection --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index 51376afd..b7fd9424 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -31,7 +31,6 @@ public static class TrustedSclIedIdentityMatcher if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); - var topologyMatches = 0; var ambiguousUnboundTopology = false; foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) { @@ -44,10 +43,7 @@ public static class TrustedSclIedIdentityMatcher foreach (var ap in ied.Elements(Scl + "AccessPoint")) { var apName = ((string?)ap.Attribute("name") ?? "").Trim(); - if (string.IsNullOrWhiteSpace(apName) || - (!string.IsNullOrWhiteSpace(selectedAccessPointName) && - !string.Equals(apName, selectedAccessPointName.Trim(), StringComparison.Ordinal))) - continue; + if (string.IsNullOrWhiteSpace(apName)) continue; var devices = ap.Element(Scl + "Server")?.Elements(Scl + "LDevice").ToArray() ?? Array.Empty(); if (devices.Length != observed.Count) continue; @@ -71,10 +67,6 @@ public static class TrustedSclIedIdentityMatcher } } if (invalid || aliases.Count != observed.Count) continue; - // Count every topology-compatible AP before endpoint filtering. - // One AP lacking ConnectedAP is still a competing possibility - // when the operator has not selected a unique AP. - topologyMatches++; var connected = trustedScl.Root.Element(Scl + "Communication")? .Descendants(Scl + "ConnectedAP") From cd3b631911adaf0dc1eaafa13bb6bdbfa70a2582 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:11:38 +0700 Subject: [PATCH 139/176] fix(identity): preserve case-sensitive MMS domain inventory and exact alias keys --- src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8c586748..9e6aeaf8 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -7,7 +7,7 @@ public sealed class LiveIedIdentity public LiveIedDiscoveryConfidenceLevel Confidence { get; init; } = LiveIedDiscoveryConfidenceLevel.Unknown; public bool IsAmbiguous { get; init; } public IReadOnlyList CandidateNames { get; init; } = Array.Empty(); - public IReadOnlyDictionary LogicalDeviceAliases { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public IReadOnlyDictionary LogicalDeviceAliases { get; init; } = new Dictionary(StringComparer.Ordinal); public IReadOnlyList Evidence { get; init; } = Array.Empty(); public string Summary => $"IED identity: name={IedName}, source={Source}, confidence={Confidence}, candidates={CandidateNames.Count}, ambiguous={IsAmbiguous.ToString().ToLowerInvariant()}"; @@ -31,8 +31,8 @@ public static LiveIedIdentity Resolve( var materialized = domains .Where(domain => !string.IsNullOrWhiteSpace(domain)) .Select(domain => domain.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(domain => domain, StringComparer.OrdinalIgnoreCase) + .Distinct(StringComparer.Ordinal) + .OrderBy(domain => domain, StringComparer.Ordinal) .ToArray(); if (!string.IsNullOrWhiteSpace(explicitIedName)) From 313af3c6bdf936b7dc314a7bcb64b9e65d71b20d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:13:23 +0700 Subject: [PATCH 140/176] test(identity): distinct MMS domain casing must not overwrite alias identity --- .../Discovery/LiveIedIdentityResolverTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 1fbeaa46..849c6049 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -106,6 +106,18 @@ public void Resolve_Rejects_Conflicting_Domain_Candidates() } + + [Fact] + public void Resolve_Preserves_Case_Distinct_Mms_Domains_And_Alias_Keys() + { + var domains = new[] { "STATIONBCUCTRL", "stationbcuctrl" }; + var identity = LiveIedIdentityResolver.Resolve(domains, "192.0.2.42"); + Assert.Equal(2, identity.LogicalDeviceAliases.Count); + Assert.Equal("CTRL", identity.LogicalDeviceAliases["STATIONBCUCTRL"]); + Assert.Equal("ctrl", identity.LogicalDeviceAliases["stationbcuctrl"]); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); + } + [Fact] public void Resolve_Fully_Known_Ld_Suffixes_Is_Still_Provisional_Without_Scl() { From ccf31f60095ea65f9095514f0fa0102060962049 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:13:27 +0700 Subject: [PATCH 141/176] fix(identity): retain exact case-sensitive MMS aliases in resolved identity --- src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 9e6aeaf8..8ce7beb3 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -168,7 +168,7 @@ private static LiveIedIdentity Create( IReadOnlyList domains, IReadOnlyList evidence) { - var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var aliases = new Dictionary(StringComparer.Ordinal); foreach (var domain in domains) { var alias = domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) && domain.Length > name.Length From 0504654af46a8311b244644f19472420916debfd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:25:54 +0700 Subject: [PATCH 142/176] test(rcb): reproduce 66/300 candidate global guard and require exact-target bounded continuation --- .../MmsCanonicalStaticRcbCatalogPagerTests.cs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs new file mode 100644 index 00000000..f5457cfb --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs @@ -0,0 +1,136 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticRcbCatalogPagerTests +{ + [Theory] + [InlineData(66)] + [InlineData(300)] + public void Proven_Target_DataSet_Does_Not_Probe_Unrelated_Rcb_Inventory(int total) + { + var catalog = Enumerable.Range(0, total - 2) + .Select(i => Candidate($"LD0/LLN0.BR.Noise{i:000}", "", enabled: false)) + .Append(Candidate("LD0/LLN0.BR.Events01", "LD0/LLN0.Events", enabled: true)) + .Append(Candidate("LD0/LLN0.BR.Events02", "LD0/LLN0.Events", enabled: false)) + .ToArray(); + var first = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 4); + var reversed = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog.Reverse().ToArray()), 4); + + Assert.Equal(2, first.Candidates.Count); + Assert.Equal(first.Candidates.Select(x => x.Reference), reversed.Candidates.Select(x => x.Reference)); + Assert.False(first.HasMore); + Assert.Equal(total, first.CatalogCount); + Assert.Equal(2, first.TargetBoundCount); + Assert.DoesNotContain(first.Candidates, x => x.Name.StartsWith("Noise", StringComparison.Ordinal)); + Assert.All(first.Candidates, x => Assert.Equal("LD0/LLN0.Events", x.DataSetReference)); + } + + [Fact] + public void Unresolved_300_Rcbs_Continues_In_Bounded_Deterministic_Pages_Without_Duplicates() + { + var catalog = Enumerable.Range(0, 300) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var inventory = Inventory(catalog); + string? continuation = null; + var visited = new List(); + var pages = 0; + do + { + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), inventory, 64, continuation); + Assert.InRange(page.Candidates.Count, 1, 64); + visited.AddRange(page.Candidates.Select(x => x.Reference)); + continuation = page.ContinuationToken; + pages++; + } while (continuation is not null && pages < 10); + + Assert.Null(continuation); + Assert.Equal(5, pages); + Assert.Equal(300, visited.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(300, visited.Count); + } + + [Fact] + public void Catalog_Or_Target_Mutation_Invalidates_Continuation() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var initial = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 16); + Assert.NotNull(initial.ContinuationToken); + var changed = catalog[..^1]; + Assert.Throws(() => + MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(changed), 16, initial.ContinuationToken)); + } + + [Fact] + public void Wrong_Dataset_And_Different_Functional_Constraint_Are_Not_Admitted() + { + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Events", "LD0/LLN0.Wrong"), + Candidate("LD0/LLN0.RP.Events", "LD0/LLN0.Events", buffered: false), + Candidate("LD0/LLN0.BR.Events02", "LD0/LLN0.Events"), + Candidate("LD1/LLN0.BR.Events03", "LD0/LLN0.Events")); + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), inventory, 32); + Assert.Equal("LD0/LLN0.BR.Events02", Assert.Single(page.Candidates).Reference); + } + + [Fact] + public void Exact_Configured_Reference_Is_Probe_Priority_But_Not_Proof_Of_Dataset() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Noise{i:000}", "")) + .Append(Candidate("LD0/LLN0.BR.Events", "")) + .ToArray(); + var page = MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(catalog), 1); + Assert.Equal("LD0/LLN0.BR.Events", Assert.Single(page.Candidates).Reference); + Assert.Equal(0, page.TargetBoundCount); + Assert.True(page.HasMore); + } + + private static CanonicalStaticReportCoveragePlan Coverage() => new() + { + Segments = + [ + new CanonicalStaticReportCoverageSegment + { + DataSetReference = "LD0/LLN0.Events", + SourceDataSetReference = "LD0/LLN0.Events", + SelectedSignalReferences = ["LD0/XCBR1.Pos.stVal"], + ReportControls = + [ + new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Events", + MmsDomain = "LD0", + Buffered = true, + DataSetReference = "LD0/LLN0.Events" + } + ] + } + ] + }; + + private static MmsReportInventory Inventory(params MmsReportControlCandidate[] candidates) + { + var inventory = new MmsReportInventory { Authority = MmsReportInventoryAuthority.LiveMmsObserved }; + inventory.ReportControls.AddRange(candidates); + return inventory; + } + + private static MmsReportControlCandidate Candidate( + string reference, string dataSet, bool enabled = false, bool buffered = true) + => new() + { + Domain = reference[..reference.IndexOf('/')], + LogicalNode = "LLN0", + FunctionalConstraint = buffered ? "BR" : "RP", + Name = reference[(reference.LastIndexOf('.') + 1)..], + Reference = reference, + Buffered = buffered, + DataSetReference = dataSet, + EnabledState = enabled ? "true" : "false" + }; +} From dbf11894b114e5fa69e5aa9017bb79a81120d78a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:27:01 +0700 Subject: [PATCH 143/176] feat(rcb): exact-target indexed read admission with stable bounded continuation --- .../Mms/MmsCanonicalStaticRcbCatalogPager.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs new file mode 100644 index 00000000..1d0477d4 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs @@ -0,0 +1,137 @@ +using System.Security.Cryptography; +using System.Text; +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +public sealed class MmsCanonicalStaticRcbCatalogPage +{ + public IReadOnlyList Candidates { get; init; } + = Array.Empty(); + public int CatalogCount { get; init; } + public int TargetBoundCount { get; init; } + public int EligibleProbeCount { get; init; } + public string? ContinuationToken { get; init; } + public bool HasMore => ContinuationToken is not null; +} + +/// +/// Read-only candidate admission for one selected canonical DataSet scope. +/// An observed DatSet binding is authority; an exact configured RCB reference +/// is only a priority for a Read, never proof of its current DatSet. +/// No sibling name guess can authorize report activation. +/// Catalog size is never a reason to abandon discovery: a page limits only +/// the number of actual read candidates and carries a checked continuation. +/// +public static class MmsCanonicalStaticRcbCatalogPager +{ + public static MmsCanonicalStaticRcbCatalogPage Select( + CanonicalStaticReportCoveragePlan coverage, + MmsReportInventory inventory, + int maxReads, + string? continuationToken = null) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(inventory); + if (maxReads <= 0) + throw new ArgumentOutOfRangeException(nameof(maxReads)); + + // Exact scoped keys are derived from selected SCL coverage, not a + // vendor-specific RCB-name pattern or an arbitrary inventory cap. + var requested = coverage.Segments + .Where(s => s.SelectedSignalReferences.Length > 0) + .SelectMany(segment => segment.ReportControls.Select(report => new + { + Domain = DomainOf(report.MmsDomain, report.Reference), + report.Buffered, + Configured = Normalize(report.Reference), + TargetDataSet = Normalize(segment.DataSetReference) + })) + .Where(target => target.Domain.Length > 0 && target.TargetDataSet.Length > 0) + .ToArray(); + + var selectedDomains = requested.Select(x => (x.Domain, x.Buffered)) + .ToHashSet(); + var targetDataSets = requested.Select(x => x.TargetDataSet) + .ToHashSet(StringComparer.Ordinal); + var configured = requested.Select(x => x.Configured) + .Where(x => x.Length > 0).ToHashSet(StringComparer.Ordinal); + + var catalog = inventory.ReportControls + .Where(c => selectedDomains.Contains((c.Domain, c.Buffered))) + .Where(c => + { + var bound = Normalize(c.DataSetReference); + return bound.Length == 0 || targetDataSets.Contains(bound); + }) + .DistinctBy(c => Normalize(c.Reference), StringComparer.Ordinal) + .OrderBy(c => c.Reference, StringComparer.Ordinal) + .ToArray(); + + var bound = catalog + .Where(c => targetDataSets.Contains(Normalize(c.DataSetReference))) + .ToArray(); + var exactUnbound = catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + configured.Contains(Normalize(c.Reference))) + .ToArray(); + + // A proven DataSet scope does not trigger a blind read of other, + // still-unbound RCBs. Without proof, an unknown candidate requires + // a real read of DatSet; exact configured references are probed first. + var probes = bound.Length > 0 + ? bound.Concat(exactUnbound).ToArray() + : exactUnbound.Concat(catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + !configured.Contains(Normalize(c.Reference)))).ToArray(); + + // The token is bound to the exact catalog/selection. On a changed + // inventory it fails closed instead of skipping an arbitrary RCB. + var input = new StringBuilder(); + foreach (var target in requested + .OrderBy(t => t.Domain, StringComparer.Ordinal) + .ThenBy(t => t.Configured, StringComparer.Ordinal) + .ThenBy(t => t.TargetDataSet, StringComparer.Ordinal)) + input.Append(target.Domain).Append('|').Append(target.Buffered) + .Append('|').Append(target.Configured).Append('|') + .Append(target.TargetDataSet).Append('\n'); + foreach (var item in catalog) + input.Append(item.Reference).Append('|').Append(item.DataSetReference).Append('\n'); + var fingerprint = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(input.ToString()))); + + var start = 0; + if (continuationToken is not null) + { + var parts = continuationToken.Split(':'); + if (parts.Length != 2 || + !string.Equals(parts[0], fingerprint, StringComparison.Ordinal) || + !int.TryParse(parts[1], out start) || start < 0 || start >= probes.Length) + throw new ArgumentException( + "RCB continuation does not belong to the current target catalog.", + nameof(continuationToken)); + } + + var size = Math.Min(maxReads, probes.Length - start); + var next = start + size; + return new MmsCanonicalStaticRcbCatalogPage + { + CatalogCount = catalog.Length, + TargetBoundCount = bound.Length, + EligibleProbeCount = probes.Length, + Candidates = probes.Skip(start).Take(size).ToArray(), + ContinuationToken = next < probes.Length ? $"{fingerprint}:{next}" : null + }; + } + + private static string Normalize(string? reference) + => MmsRcbAvailabilityEvaluator.NormalizeReference(reference); + + private static string DomainOf(string? domain, string? reference) + { + if (!string.IsNullOrWhiteSpace(domain)) + return domain.Trim(); + var slash = reference?.IndexOf('/') ?? -1; + return slash > 0 ? reference![..slash] : string.Empty; + } +} From b2d3519465e05201f2373e1b13169f91ae697b67 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:28:37 +0700 Subject: [PATCH 144/176] fix(rcb): replace global 64/128 admission abort with exact dataset scoped bounded read pages --- .../MmsCanonicalStaticLiveReconciliation.cs | 166 +++++++++++------- 1 file changed, 103 insertions(+), 63 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs index d2f3b82e..ed7a4d8f 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -51,10 +51,16 @@ public sealed class MmsCanonicalStaticLiveReconciliationOptions public int UnknownPeerMaxConcurrentDomains { get; init; } = 2; /// - /// Maximum exact live RCB candidates that may be read for DatSet binding evidence. - /// The operation fails closed rather than reading an arbitrary subset. + /// Maximum RCB Read candidates PER PAGE. A partial catalog is resumable; + /// the total number of unrelated RCBs must never abort selected discovery. /// public int MaxReportControlCandidates { get; init; } = 64; + + /// Bounded batches per invocation; never a global catalog cap. + public int MaxCandidatePagesPerInvocation { get; init; } = 8; + + /// Checked catalog continuation from a previously partial reconciliation. + public string? CandidateContinuationToken { get; init; } } public sealed class MmsCanonicalStaticLiveReconciliationResult @@ -65,6 +71,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult public int EnumeratedVariableCount { get; init; } public int CandidateReportControlCount { get; init; } public int DataSetBoundReportControlCount { get; init; } + public int ExaminedReportControlCount { get; init; } + public int DeferredReportControlCount { get; init; } + public string? CandidateContinuationToken { get; init; } public IReadOnlyList Warnings { get; init; } = Array.Empty(); public bool HasLiveInventory => @@ -73,7 +82,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}."; + $"datasetBound={DataSetBoundReportControlCount}, examined={ExaminedReportControlCount}, " + + $"deferred={DeferredReportControlCount}, authority={Inventory.Authority}."; } public sealed class MmsCanonicalStaticSmartPreparationResult @@ -265,75 +275,103 @@ 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() - }; - } + // The raw RCB directory has no authoritative DatSet association yet. + // Stage A: index by the exact selected DataSet and configured references. + // Stage B: read only bounded pages of still-unknown compatible candidates. + // Never reject the complete operation because *global* RCB count is 66/300. + // Directory/catalog objects remain immutable while pages are calculated; + // successful MMS reads enrich separate candidates, not continuation state. + var maxReadsPerPage = Math.Clamp(options.MaxReportControlCandidates, 1, 512); + var maxReadPages = Math.Clamp(options.MaxCandidatePagesPerInvocation, 1, 32); + var expectedDataSets = coverage.Segments + .Where(segment => segment.SelectedSignalReferences.Length > 0) + .Select(segment => MmsRcbAvailabilityEvaluator.NormalizeReference(segment.DataSetReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.Ordinal); var inventory = new MmsReportInventory { Authority = MmsReportInventoryAuthority.LiveMmsObserved }; - inventory.ReportControls.AddRange(candidates); - - foreach (var candidate in candidates) + var examined = 0; + var rejected = 0; + var catalogCount = 0; + var eligibleProbeCount = 0; + var pages = 0; + var continuation = options.CandidateContinuationToken; + + do { cancellationToken.ThrowIfCancellationRequested(); - - await TryReadReportControlStructureSmartAsync( - candidate, - cancellationToken) - .ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + // The pager binds its cursor to exact catalog content and coverage. + // A changed/ambiguous cursor must fail closed, not silently skip RCBs. + var page = MmsCanonicalStaticRcbCatalogPager.Select( + coverage, observed, maxReadsPerPage, continuation); + catalogCount = page.CatalogCount; + eligibleProbeCount = page.EligibleProbeCount; + continuation = page.ContinuationToken; + pages++; + + foreach (var raw in page.Candidates) { - await TryReadReportAttributeSmartAsync( - candidate, - "DatSet", - cancellationToken) - .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + break; + + // Do not mutate the directory source used for stable paging. + var candidate = new MmsReportControlCandidate + { + Domain = raw.Domain, + LogicalNode = raw.LogicalNode, + FunctionalConstraint = raw.FunctionalConstraint, + Name = raw.Name, + Reference = raw.Reference, + Buffered = raw.Buffered, + DataSetReference = raw.DataSetReference, + Attributes = raw.Attributes.ToList() + }; + examined++; + if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + { + await TryReadReportControlStructureSmartAsync( + candidate, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(candidate.DataSetReference)) + await TryReadReportAttributeSmartAsync( + candidate, "DatSet", cancellationToken).ConfigureAwait(false); + } + + // Exact live DatSet equality is mandatory. A matching SCL RCB + // name alone cannot become an operational report target. + if (expectedDataSets.Contains( + MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference))) + inventory.ReportControls.Add(candidate); + else + rejected++; } - - if (!IsMmsInitiated) - break; - } - - var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete); + } while (continuation is not null && + pages < maxReadPages && + IsMmsInitiated); + + var complete = IsMmsInitiated && + chains.All(chain => chain.IsComplete) && + continuation is null; + var coveredDataSets = inventory.ReportControls + .Select(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference)) + .ToHashSet(StringComparer.Ordinal); + var unresolved = expectedDataSets.Where(ds => !coveredDataSets.Contains(ds)).ToArray(); var warnings = plan.Warnings .Concat(chains .Where(chain => !chain.IsComplete) .Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}")) - .Concat(candidates - .Where(candidate => string.IsNullOrWhiteSpace(candidate.DataSetReference)) - .Select(candidate => $"Live RCB '{candidate.Reference}' did not expose a usable DatSet binding.")) + .Concat(rejected > 0 + ? new[] { $"{rejected} probed RCB(s) had no exact selected live DatSet binding; not admitted for activation." } + : Array.Empty()) + .Concat(continuation is not null + ? new[] { $"Target-scoped RCB discovery deferred {Math.Max(0, eligibleProbeCount - examined)} candidate(s) after {pages} bounded page(s); resume using CandidateContinuationToken." } + : Array.Empty()) + .Concat(unresolved.Select(ds => + $"Selected DataSet '{ds}' has no verified live RCB binding in this acquisition window.")) .Distinct(StringComparer.Ordinal) .ToArray(); @@ -342,14 +380,16 @@ await TryReadReportAttributeSmartAsync( Plan = plan, Status = !IsMmsInitiated ? MmsCanonicalStaticLiveReconciliationStatus.AssociationLost - : complete + : complete && unresolved.Length == 0 ? MmsCanonicalStaticLiveReconciliationStatus.Ready : MmsCanonicalStaticLiveReconciliationStatus.Partial, Inventory = inventory, EnumeratedVariableCount = variables.Values.Sum(list => list.Count), - CandidateReportControlCount = candidates.Length, - DataSetBoundReportControlCount = candidates.Count(candidate => - !string.IsNullOrWhiteSpace(candidate.DataSetReference)), + CandidateReportControlCount = catalogCount, + DataSetBoundReportControlCount = inventory.ReportControls.Count, + ExaminedReportControlCount = examined, + DeferredReportControlCount = Math.Max(0, eligibleProbeCount - examined), + CandidateContinuationToken = continuation, Warnings = warnings }; } From dc31e0f4e2716fbd5ba0698e98c2182fa4a4c7a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:35:39 +0700 Subject: [PATCH 145/176] fix(rcb): fence continuation cursor to accepted MMS association lifetime --- src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs index 1d0477d4..98cc7974 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs @@ -29,7 +29,8 @@ public static MmsCanonicalStaticRcbCatalogPage Select( CanonicalStaticReportCoveragePlan coverage, MmsReportInventory inventory, int maxReads, - string? continuationToken = null) + string? continuationToken = null, + string? associationKey = null) { ArgumentNullException.ThrowIfNull(coverage); ArgumentNullException.ThrowIfNull(inventory); @@ -88,6 +89,7 @@ public static MmsCanonicalStaticRcbCatalogPage Select( // The token is bound to the exact catalog/selection. On a changed // inventory it fails closed instead of skipping an arbitrary RCB. var input = new StringBuilder(); + input.Append("association=").Append(associationKey ?? "detached").Append('\n'); foreach (var target in requested .OrderBy(t => t.Domain, StringComparer.Ordinal) .ThenBy(t => t.Configured, StringComparer.Ordinal) From f2765f552bb6872b287477ea174cad22136ccc81 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:35:42 +0700 Subject: [PATCH 146/176] fix(rcb): fence continuation cursor to accepted MMS association lifetime --- src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs index ed7a4d8f..26537892 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -191,6 +191,8 @@ private static string ResolveDomain( public sealed partial class MmsClientSession { + private string _canonicalStaticRcbAssociationKey = Guid.NewGuid().ToString("N"); + /// /// Performs bounded online reconciliation for configured static reporting without /// running full IED discovery. @@ -306,7 +308,8 @@ public async Task ReconcileCanonical // The pager binds its cursor to exact catalog content and coverage. // A changed/ambiguous cursor must fail closed, not silently skip RCBs. var page = MmsCanonicalStaticRcbCatalogPager.Select( - coverage, observed, maxReadsPerPage, continuation); + coverage, observed, maxReadsPerPage, continuation, + _canonicalStaticRcbAssociationKey); catalogCount = page.CatalogCount; eligibleProbeCount = page.EligibleProbeCount; continuation = page.ContinuationToken; From f9f01894e199f43122339e66f34860c1d0c0b243 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:35:46 +0700 Subject: [PATCH 147/176] fix(rcb): fence continuation cursor to accepted MMS association lifetime --- src/AR.Iec61850/Mms/MmsClientSession.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs index 636182e7..0ca63d33 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.cs @@ -764,6 +764,8 @@ private async Task AssociateAsync(bool resetAssociationDiagnostics, Cancellation if (result.IsAccepted) { State = MmsAssociationState.MmsInitiated; + // Bind paged RCB discovery to this accepted association. + _canonicalStaticRcbAssociationKey = Guid.NewGuid().ToString("N"); LastHandshakeMessage = result.Message; ResetReportSemanticTypeEvidence(); ResetConfiguredStaticDataSetDirectoryEvidence(); From 7f96daf8094123160deeb6677f2623f21a0ad565 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:35:50 +0700 Subject: [PATCH 148/176] fix(rcb): fence continuation cursor to accepted MMS association lifetime --- .../Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs index f5457cfb..15b2e290 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs @@ -65,6 +65,21 @@ public void Catalog_Or_Target_Mutation_Invalidates_Continuation() MmsCanonicalStaticRcbCatalogPager.Select(Coverage(), Inventory(changed), 16, initial.ContinuationToken)); } + [Fact] + public void Reconnect_Must_Reject_Prior_Association_Cursor() + { + var catalog = Enumerable.Range(0, 66) + .Select(i => Candidate($"LD0/LLN0.BR.Unbound{i:000}", "")) + .ToArray(); + var inventory = Inventory(catalog); + var old = MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), inventory, 16, associationKey: "epoch-a"); + Assert.NotNull(old.ContinuationToken); + Assert.Throws(() => + MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), inventory, 16, old.ContinuationToken, "epoch-b")); + } + [Fact] public void Wrong_Dataset_And_Different_Functional_Constraint_Are_Not_Admitted() { From adffc518305926e1aa5b4829ab4bb37ff7a80e1e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:37:17 +0700 Subject: [PATCH 149/176] test(rcb): prove occupied matching RCB blocked while exact free sibling eligible --- .../MmsCanonicalStaticRcbCatalogPagerTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs index 15b2e290..445b1e45 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs @@ -25,8 +25,48 @@ public void Proven_Target_DataSet_Does_Not_Probe_Unrelated_Rcb_Inventory(int tot Assert.Equal(2, first.TargetBoundCount); Assert.DoesNotContain(first.Candidates, x => x.Name.StartsWith("Noise", StringComparison.Ordinal)); Assert.All(first.Candidates, x => Assert.Equal("LD0/LLN0.Events", x.DataSetReference)); + + // Read-only discovery sees both matching RCBs; activation policy must + // reject the occupied one and choose the independently proven free one. + var occupied = first.Candidates.Single(x => x.EnabledState == "true"); + var free = first.Candidates.Single(x => x.EnabledState == "false"); + Assert.False(MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + VerifiedAvailability(occupied, MmsRcbOperationalAvailability.InUse, "60"), + allowCallerOwned: false, + allowReducedMissingReservationEvidence: false).IsEligible); + var eligibility = MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + VerifiedAvailability(free, MmsRcbOperationalAvailability.Available, "0"), + allowCallerOwned: false, + allowReducedMissingReservationEvidence: false); + Assert.Equal(MmsConfiguredStaticRcbEligibilityKind.ExplicitFree, eligibility.Kind); } + private static MmsRcbAvailabilitySnapshot VerifiedAvailability( + MmsReportControlCandidate candidate, + MmsRcbOperationalAvailability state, + string reservation) + => new() + { + Reference = candidate.Reference, + Domain = candidate.Domain, + LogicalNode = candidate.LogicalNode, + Buffered = candidate.Buffered, + DataSetReference = candidate.DataSetReference, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = candidate.EnabledState, + ReservationTimeSeconds = reservation, + DataSetDirectorySuccess = true, + DataSetMembers = [new MmsDataSetDirectoryMember + { + Domain = "LD0", + MmsItemName = "XCBR1$ST$Pos", + UserReference = "LD0/XCBR1.Pos", + FunctionalConstraint = "ST" + }], + Availability = state, + Confidence = MmsRcbAvailabilityConfidence.Exact + }; + [Fact] public void Unresolved_300_Rcbs_Continues_In_Bounded_Deterministic_Pages_Without_Duplicates() { From 37cd183e1f9a5c8217b9bb11832e8503915444b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:43:04 +0700 Subject: [PATCH 150/176] perf(rcb): prioritize indexed configured RCB instances for read-only proof, not activation --- .../Mms/MmsCanonicalStaticRcbCatalogPager.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs index 98cc7974..47f96285 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs @@ -80,11 +80,22 @@ public static MmsCanonicalStaticRcbCatalogPage Select( // A proven DataSet scope does not trigger a blind read of other, // still-unbound RCBs. Without proof, an unknown candidate requires // a real read of DatSet; exact configured references are probed first. + // A concrete indexed RCB instance (e.g. Events01 for declared + // Events) is ONLY a prioritization hint for a read-only DatSet probe. + // This never constitutes exact DataSet identity or activation proof. + var indexedUnbound = catalog + .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && + !configured.Contains(Normalize(c.Reference)) && + configured.Any(reference => + IsIndexedInstance(Normalize(c.Reference), reference))) + .ToArray(); + var probes = bound.Length > 0 ? bound.Concat(exactUnbound).ToArray() - : exactUnbound.Concat(catalog + : exactUnbound.Concat(indexedUnbound).Concat(catalog .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && - !configured.Contains(Normalize(c.Reference)))).ToArray(); + !configured.Contains(Normalize(c.Reference)) && + !indexedUnbound.Contains(c))).ToArray(); // The token is bound to the exact catalog/selection. On a changed // inventory it fails closed instead of skipping an arbitrary RCB. @@ -126,6 +137,19 @@ public static MmsCanonicalStaticRcbCatalogPage Select( }; } + private static bool IsIndexedInstance(string candidate, string configured) + { + if (configured.Length == 0 || candidate.Length <= configured.Length || + !candidate.StartsWith(configured, StringComparison.Ordinal)) + return false; + // Exact numeric instance suffix only: no arbitrary text similarity, + // and still no usable target until the live DatSet read succeeds. + for (var i = configured.Length; i < candidate.Length; i++) + if (candidate[i] is < '0' or > '9') + return false; + return true; + } + private static string Normalize(string? reference) => MmsRcbAvailabilityEvaluator.NormalizeReference(reference); From c79ee7d18ea0d343b453911fc0254b9a88bad4a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:43:22 +0700 Subject: [PATCH 151/176] perf(rcb): hash-index prioritized candidate exclusion rather than repeated list search --- src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs index 47f96285..f436ba52 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCatalogPager.cs @@ -89,13 +89,14 @@ public static MmsCanonicalStaticRcbCatalogPage Select( configured.Any(reference => IsIndexedInstance(Normalize(c.Reference), reference))) .ToArray(); + var indexedSet = indexedUnbound.ToHashSet(); var probes = bound.Length > 0 ? bound.Concat(exactUnbound).ToArray() : exactUnbound.Concat(indexedUnbound).Concat(catalog .Where(c => string.IsNullOrWhiteSpace(c.DataSetReference) && !configured.Contains(Normalize(c.Reference)) && - !indexedUnbound.Contains(c))).ToArray(); + !indexedSet.Contains(c))).ToArray(); // The token is bound to the exact catalog/selection. On a changed // inventory it fails closed instead of skipping an arbitrary RCB. From 954e48f583000ccfc44a36076acb9091a90990b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:44:03 +0700 Subject: [PATCH 152/176] perf(rcb): stop paged reads after each exact DataSet has strong non-busy evidence --- .../MmsCanonicalStaticLiveReconciliation.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs index 26537892..414c24a7 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -352,6 +352,24 @@ await TryReadReportAttributeSmartAsync( else rejected++; } + + // Stop additional catalog pages once every selected DataSet has + // one exact observed binding with explicit read-only "appears free" + // evidence. This is NOT activation authorization: the subsequent + // exact-target availability/owner/directory checks still decide. + // A busy or reservation-unknown RCB never prematurely ends search. + var allTargetsHaveFreeEvidence = expectedDataSets.Count > 0 && + expectedDataSets.All(dataSet => inventory.ReportControls.Any(candidate => + string.Equals( + MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference), + dataSet, StringComparison.Ordinal) && + MmsRcbAvailabilityEvaluator.ParseBool(candidate.EnabledState) == false && + !MmsRcbAvailabilityEvaluator.HasOwner(candidate.Owner) && + (candidate.Buffered + ? MmsRcbAvailabilityEvaluator.ParseUnsigned(candidate.ReservationTimeSeconds) == 0 + : MmsRcbAvailabilityEvaluator.ParseBool(candidate.ReservationState) == false))); + if (allTargetsHaveFreeEvidence) + break; } while (continuation is not null && pages < maxReadPages && IsMmsInitiated); From 3d4599f1fbc5a4f9fe9f1656006e32b8d8cb452c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:44:23 +0700 Subject: [PATCH 153/176] test(rcb): prioritize two indexed instances in 302 catalog without false binding proof --- .../MmsCanonicalStaticRcbCatalogPagerTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs index 445b1e45..b919cd23 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs @@ -92,6 +92,26 @@ public void Unresolved_300_Rcbs_Continues_In_Bounded_Deterministic_Pages_Without Assert.Equal(300, visited.Count); } + [Fact] + public void Indexed_Configured_Family_Is_Read_Priority_Not_Trusted_DataSet_Proof() + { + var catalog = Enumerable.Range(0, 300) + .Select(i => Candidate($"LD0/LLN0.BR.Unrelated{i:000}", "")) + .Append(Candidate("LD0/LLN0.BR.Events01", "")) + .Append(Candidate("LD0/LLN0.BR.Events02", "")) + .ToArray(); + var first = MmsCanonicalStaticRcbCatalogPager.Select( + Coverage(), Inventory(catalog), 2); + Assert.Equal(300, first.CatalogCount + 2); + Assert.Equal(0, first.TargetBoundCount); + Assert.Equal(2, first.Candidates.Count); + Assert.Equal( + new[] { "LD0/LLN0.BR.Events01", "LD0/LLN0.BR.Events02" }, + first.Candidates.Select(c => c.Reference).ToArray()); + Assert.True(first.HasMore); + Assert.All(first.Candidates, c => Assert.Equal("", c.DataSetReference)); + } + [Fact] public void Catalog_Or_Target_Mutation_Invalidates_Continuation() { From 6391244a14a6d8268e7a6252677ccf0f9ce14353 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:44:37 +0700 Subject: [PATCH 154/176] test(rcb): correct 300 unrelated plus two relevant catalog cardinality --- .../Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs index b919cd23..76beb371 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCatalogPagerTests.cs @@ -102,7 +102,7 @@ public void Indexed_Configured_Family_Is_Read_Priority_Not_Trusted_DataSet_Proof .ToArray(); var first = MmsCanonicalStaticRcbCatalogPager.Select( Coverage(), Inventory(catalog), 2); - Assert.Equal(300, first.CatalogCount + 2); + Assert.Equal(302, first.CatalogCount); Assert.Equal(0, first.TargetBoundCount); Assert.Equal(2, first.Candidates.Count); Assert.Equal( From 1a3d82b513ba7c152cd944bfd63453abe3026451 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:46:00 +0700 Subject: [PATCH 155/176] test(rcb): reject wrong live DatSet even when configured RCB reference matches --- ...msCanonicalStaticRcbTargetResolverTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs index 12bcbef0..5293bf0c 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs @@ -54,6 +54,25 @@ public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_N Assert.Equal("exact-configured-rcb-reference", resolution.Segments.Single().Resolution); } + [Fact] + public void Exact_Configured_Reference_With_Proven_Wrong_DataSet_Cannot_Be_Target() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Events", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Events", "LD0/LLN0.Other", buffered: true)); + + var result = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + Assert.False(result.HasTargets); + Assert.Equal("unresolved", Assert.Single(result.Segments).Resolution); + } + [Fact] public void Unresolved_Segment_Does_Not_Guess_Name_Family() { From edf929470ab50a3e3eec363718ca43585dd60be8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:46:11 +0700 Subject: [PATCH 156/176] fix(rcb): refuse exact-name fallback when live DatSet binding contradicts target --- .../Mms/MmsCanonicalStaticRcbTargetResolver.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs index da44d3b8..8e12ae40 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs @@ -66,6 +66,15 @@ public static MmsCanonicalStaticRcbTargetResolution Resolve( var exactReferenceMatches = liveInventory.ReportControls .Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference))) + .Where(candidate => + configuredBufferedFamilies.Count == 0 || + configuredBufferedFamilies.Contains(candidate.Buffered)) + .Where(candidate => + canonicalDataSet.Length == 0 || + string.IsNullOrWhiteSpace(candidate.DataSetReference) || + string.Equals( + Normalize(candidate.DataSetReference), + canonicalDataSet, StringComparison.OrdinalIgnoreCase)) .ToArray(); var exactDataSetMatches = canonicalDataSet.Length == 0 From 59ecf1b901d0bda872fb3b3044ac43e326cd2788 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:50:04 +0700 Subject: [PATCH 157/176] feat(rcb): bounded resumable exact-target availability pager without global abort --- .../Mms/MmsCanonicalStaticExactTargetPager.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs new file mode 100644 index 00000000..42c72f26 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticExactTargetPager.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AR.Iec61850.Mms; + +public sealed class MmsCanonicalStaticExactTargetPage +{ + public IReadOnlySet References { get; init; } = + new HashSet(StringComparer.Ordinal); + public int TotalExactTargets { get; init; } + public string? ContinuationToken { get; init; } + public bool HasMore => ContinuationToken is not null; +} + +/// +/// Resumable exact-target availability admission. Unlike a global 64/128 RCB +/// abort, this pure selector constrains work per page. It cannot add targets +/// missing from the verified live inventory or invent indexed RCB siblings. +/// +public static class MmsCanonicalStaticExactTargetPager +{ + public static MmsCanonicalStaticExactTargetPage Select( + MmsCanonicalStaticAcquisitionProbePlan plan, + MmsReportInventory inventory, + int maxPerPage, + string? continuationToken = null, + string? associationKey = null) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(inventory); + if (maxPerPage <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPerPage)); + if (inventory.Authority != MmsReportInventoryAuthority.LiveMmsObserved) + throw new ArgumentException("Exact target paging requires live MMS inventory.", nameof(inventory)); + + var liveReferences = inventory.ReportControls + .Select(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var targets = plan.ExactTargetReportControlReferences + .Select(MmsRcbAvailabilityEvaluator.NormalizeReference) + .Where(reference => reference.Length > 0 && liveReferences.Contains(reference)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(reference => reference, StringComparer.Ordinal) + .ToArray(); + var input = new StringBuilder().Append("association=") + .Append(associationKey ?? "detached").Append('\n'); + foreach (var reference in targets) + input.Append(reference).Append('\n'); + var fingerprint = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(input.ToString()))); + + var start = 0; + if (continuationToken is not null) + { + var parts = continuationToken.Split(':'); + if (parts.Length != 2 || + !string.Equals(parts[0], fingerprint, StringComparison.Ordinal) || + !int.TryParse(parts[1], out start) || start < 0 || start >= targets.Length) + throw new ArgumentException( + "Exact RCB target continuation does not match current association/catalog.", + nameof(continuationToken)); + } + var next = Math.Min(targets.Length, start + maxPerPage); + return new MmsCanonicalStaticExactTargetPage + { + TotalExactTargets = targets.Length, + References = targets.Skip(start).Take(next - start) + .ToHashSet(StringComparer.OrdinalIgnoreCase), + ContinuationToken = next < targets.Length ? $"{fingerprint}:{next}" : null + }; + } +} From 15f9d55204b8bedfbb7d046a617ba140b139749f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:50:36 +0700 Subject: [PATCH 158/176] test(rcb): replace preflight global target budget rejection with 66/300 paging regression --- ...anonicalStaticAcquisitionPreflightTests.cs | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs index 484ebd86..d3a64b96 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticAcquisitionPreflightTests.cs @@ -146,11 +146,14 @@ public async Task ExecutorRejectsInventoryAuthoritySwapBeforeNetworkWork() Assert.Null(result.Availability); } - [Fact] - public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe() + [Theory] + [InlineData(66, 2)] + [InlineData(300, 5)] + public void Exact_Targets_Are_Paged_Without_The_Previous_Global_Budget_Abort( + int count, int expectedPages) { - var targetSet = Enumerable.Range(1, 3) - .Select(index => $"LD0/LLN0.BR.Rpt{index:00}") + var targetSet = Enumerable.Range(1, count) + .Select(index => $"LD0/LLN0.BR.Rpt{index:000}") .ToHashSet(StringComparer.OrdinalIgnoreCase); var targetResolution = new MmsCanonicalStaticRcbTargetResolution { @@ -168,35 +171,41 @@ public async Task TargetBudgetFailsBeforeAnyNetworkWork_AndNeverRunsPartialProbe var plan = new MmsCanonicalStaticAcquisitionProbePlan { InventoryAuthority = MmsReportInventoryAuthority.LiveMmsObserved, - Coverage = Coverage( - Segment( - "LD0/LLN0.Events", - "LD0/LLN0.BR.Rpt01", - buffered: true, - "LD0/XCBR1.Pos.stVal")), + Coverage = Coverage(Segment( + "LD0/LLN0.Events", "LD0/LLN0.BR.Rpt", + buffered: true, "LD0/XCBR1.Pos.stVal")), TargetResolution = targetResolution, Status = MmsCanonicalStaticAcquisitionProbeStatus.ExactTargetsReady, ExactTargetReportControlReferences = targetSet }; + var inventory = Inventory( + MmsReportInventoryAuthority.LiveMmsObserved, + targetSet.Select(r => Candidate(r, "LD0/LLN0.Events", true)).ToArray()); - await using var session = new MmsClientSession(); - var result = await session.ProbeCanonicalStaticAcquisitionAsync( - plan, - Inventory( - MmsReportInventoryAuthority.LiveMmsObserved, - Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Events", true), - Candidate("LD0/LLN0.BR.Rpt02", "LD0/LLN0.Events", true), - Candidate("LD0/LLN0.BR.Rpt03", "LD0/LLN0.Events", true)), - options: new MmsCanonicalStaticAcquisitionProbeOptions - { - MaxExactTargets = 2 - }); + var examined = new HashSet(StringComparer.OrdinalIgnoreCase); + string? cursor = null; + var pages = 0; + do + { + var page = MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, cursor, "active-association"); + Assert.InRange(page.References.Count, 1, 64); + foreach (var reference in page.References) + Assert.True(examined.Add(reference), "Duplicate exact target across pages."); + cursor = page.ContinuationToken; + pages++; + } while (cursor is not null && pages < 10); - Assert.Equal(MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded, result.Status); - Assert.False(result.NetworkProbePerformed); - Assert.Null(result.Availability); - Assert.Contains(result.Warnings, warning => - warning.Contains("no partial probe", StringComparison.OrdinalIgnoreCase)); + Assert.Null(cursor); + Assert.Equal(count, examined.Count); + Assert.Equal(expectedPages, pages); + + var first = MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, associationKey: "active-association"); + Assert.NotNull(first.ContinuationToken); + Assert.Throws(() => + MmsCanonicalStaticExactTargetPager.Select( + plan, inventory, 64, first.ContinuationToken, "reconnected-association")); } private static CanonicalStaticReportCoveragePlan Coverage( From e8b6be6d9526c9aab5214123b42c8d68c6f5c5f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:51:34 +0700 Subject: [PATCH 159/176] fix(rcb): page exact-target availability and stop on safe eligible candidate per dataset --- .../MmsCanonicalStaticAcquisitionPreflight.cs | 124 +++++++++++++----- 1 file changed, 91 insertions(+), 33 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs index f1d66004..68b8e47f 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -43,7 +43,10 @@ public sealed class MmsCanonicalStaticAcquisitionProbePlan public sealed class MmsCanonicalStaticAcquisitionProbeOptions { + /// Maximum exact RCBs inspected per page, not a global catalog cap. public int MaxExactTargets { get; init; } = 64; + public int MaxExactTargetPagesPerInvocation { get; init; } = 8; + public string? ExactTargetContinuationToken { get; init; } public bool ReadDataSetDirectories { get; init; } = true; public IReadOnlySet CallerOwnedRcbReferences { get; init; } = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -54,6 +57,9 @@ public sealed class MmsCanonicalStaticAcquisitionProbeResult public MmsCanonicalStaticAcquisitionProbePlan Plan { get; init; } = new(); public MmsCanonicalStaticAcquisitionProbeStatus Status { get; init; } public bool NetworkProbePerformed { get; init; } + public int ExactTargetPageCount { get; init; } + public int DeferredExactTargetCount { get; init; } + public string? ExactTargetContinuationToken { get; init; } public MmsRcbAvailabilityResult? Availability { get; init; } public IReadOnlyList Warnings { get; init; } = Array.Empty(); @@ -247,47 +253,99 @@ public async Task ProbeCanonicalStatic }; } - var maxTargets = Math.Clamp(options.MaxExactTargets, 1, 512); - if (plan.ExactTargetReportControlReferences.Count > maxTargets) + var maxPerPage = Math.Clamp(options.MaxExactTargets, 1, 512); + var maxPages = Math.Clamp(options.MaxExactTargetPagesPerInvocation, 1, 32); + EnsureMmsReady(); + + var snapshots = new List(); + var probeWarnings = new List(); + var continuation = options.ExactTargetContinuationToken; + var totalExact = 0; + var pages = 0; + var stateReads = 0; + var directoryReads = 0; + var directoryHits = 0; + + while (pages < maxPages && IsMmsInitiated) { - return new MmsCanonicalStaticAcquisitionProbeResult - { - Plan = plan, - Status = MmsCanonicalStaticAcquisitionProbeStatus.TargetBudgetExceeded, - NetworkProbePerformed = false, - Warnings = plan.Warnings - .Concat(new[] - { - $"Exact static RCB target count {plan.ExactTargetReportControlReferences.Count} exceeds the configured hot-path budget {maxTargets}; no partial probe was started." - }) - .ToArray() - }; - } + cancellationToken.ThrowIfCancellationRequested(); + var page = MmsCanonicalStaticExactTargetPager.Select( + plan, liveInventory, maxPerPage, continuation, + _canonicalStaticRcbAssociationKey); + totalExact = page.TotalExactTargets; + continuation = page.ContinuationToken; + if (page.References.Count == 0) + break; - EnsureMmsReady(); + pages++; + // Exact target list, never a broad RCB availability probe. + var batch = await CheckReportControlAvailabilityAsync( + liveInventory, + directory, + new MmsRcbAvailabilityOptions + { + MaxReportControls = maxPerPage, + ReadDataSetDirectories = options.ReadDataSetDirectories, + TargetReportControlReferences = page.References, + CallerOwnedRcbReferences = options.CallerOwnedRcbReferences + }, + cancellationToken).ConfigureAwait(false); + snapshots.AddRange(batch.ReportControls); + probeWarnings.AddRange(batch.Warnings); + stateReads += batch.TargetedRcbStateLogicalReadCount; + directoryReads += batch.DataSetDirectoryNetworkReadCount; + directoryHits += batch.DataSetDirectoryCacheHitCount; - var availability = await CheckReportControlAvailabilityAsync( - liveInventory, - directory, - new MmsRcbAvailabilityOptions - { - MaxReportControls = maxTargets, - ReadDataSetDirectories = options.ReadDataSetDirectories, - TargetReportControlReferences = plan.ExactTargetReportControlReferences, - CallerOwnedRcbReferences = options.CallerOwnedRcbReferences - }, - cancellationToken).ConfigureAwait(false); + // Once each resolved DataSet segment has an independently eligible + // exact RCB, additional pages provide no benefit to this one-shot + // selected acquisition. Availability is READ-ONLY; the consumer + // still verifies ordered members and user authorization before writes. + var resolvedSegments = plan.TargetResolution.Segments + .Where(segment => segment.ExactLiveReportControlReferences.Count > 0) + .ToArray(); + var allHaveEligible = resolvedSegments.Length > 0 && + resolvedSegments.All(segment => + snapshots.Any(snapshot => + segment.ExactLiveReportControlReferences.Any(reference => + string.Equals( + MmsRcbAvailabilityEvaluator.NormalizeReference(reference), + MmsRcbAvailabilityEvaluator.NormalizeReference(snapshot.Reference), + StringComparison.OrdinalIgnoreCase)) && + MmsConfiguredStaticRcbEligibilityPolicy.Evaluate( + snapshot, allowCallerOwned: true, + allowReducedMissingReservationEvidence: true).IsEligible)); + if (allHaveEligible || continuation is null) + break; + } - var status = plan.UnresolvedSegmentCount > 0 - ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked - : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked; + var deferred = Math.Max(0, totalExact - snapshots.Count); + if (continuation is not null) + probeWarnings.Add( + $"Exact target availability deferred {deferred} RCB(s) after {pages} bounded page(s); resume using ExactTargetContinuationToken."); + var availability = new MmsRcbAvailabilityResult + { + ReportControls = snapshots, + Warnings = probeWarnings.Distinct(StringComparer.Ordinal).ToArray(), + InventoryReportControlCount = liveInventory.ReportControls.Count, + TargetFilterApplied = true, + RequestedTargetReportControlCount = plan.ExactTargetReportControlReferences.Count, + MatchedTargetReportControlCount = snapshots.Count, + TargetedRcbStateLogicalReadCount = stateReads, + DataSetDirectoryNetworkReadCount = directoryReads, + DataSetDirectoryCacheHitCount = directoryHits + }; return new MmsCanonicalStaticAcquisitionProbeResult { Plan = plan, - Status = status, - NetworkProbePerformed = true, - Availability = availability, + Status = continuation is not null || plan.UnresolvedSegmentCount > 0 + ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked + : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked, + NetworkProbePerformed = pages > 0, + Availability = pages > 0 ? availability : null, + ExactTargetPageCount = pages, + DeferredExactTargetCount = deferred, + ExactTargetContinuationToken = continuation, Warnings = plan.Warnings .Concat(availability.Warnings) .Distinct(StringComparer.Ordinal) From d9e795275447ea1dcc3bea435b57f948595de310 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:52:06 +0700 Subject: [PATCH 160/176] fix(rcb): count deferred exact targets by actual probe admission, not response count --- .../Mms/MmsCanonicalStaticAcquisitionPreflight.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs index 68b8e47f..9070d02a 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticAcquisitionPreflight.cs @@ -262,6 +262,7 @@ public async Task ProbeCanonicalStatic var continuation = options.ExactTargetContinuationToken; var totalExact = 0; var pages = 0; + var targetedProbes = 0; var stateReads = 0; var directoryReads = 0; var directoryHits = 0; @@ -278,6 +279,7 @@ public async Task ProbeCanonicalStatic break; pages++; + targetedProbes += page.References.Count; // Exact target list, never a broad RCB availability probe. var batch = await CheckReportControlAvailabilityAsync( liveInventory, @@ -318,7 +320,7 @@ public async Task ProbeCanonicalStatic break; } - var deferred = Math.Max(0, totalExact - snapshots.Count); + var deferred = Math.Max(0, totalExact - targetedProbes); if (continuation is not null) probeWarnings.Add( $"Exact target availability deferred {deferred} RCB(s) after {pages} bounded page(s); resume using ExactTargetContinuationToken."); @@ -338,7 +340,7 @@ public async Task ProbeCanonicalStatic return new MmsCanonicalStaticAcquisitionProbeResult { Plan = plan, - Status = continuation is not null || plan.UnresolvedSegmentCount > 0 + Status = !IsMmsInitiated || pages == 0 || continuation is not null || plan.UnresolvedSegmentCount > 0 ? MmsCanonicalStaticAcquisitionProbeStatus.PartialAvailabilityChecked : MmsCanonicalStaticAcquisitionProbeStatus.AvailabilityChecked, NetworkProbePerformed = pages > 0, From ad7e65bec6204ea0cc4fe2bbd315bb217f63e993 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:23:41 +0700 Subject: [PATCH 161/176] fix(mms): reject malformed primitive wire payloads instead of fabricating false or zero --- src/AR.Iec61850/Mms/MmsDataCodec.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsDataCodec.cs b/src/AR.Iec61850/Mms/MmsDataCodec.cs index 086ed318..ff6ad0d5 100644 --- a/src/AR.Iec61850/Mms/MmsDataCodec.cs +++ b/src/AR.Iec61850/Mms/MmsDataCodec.cs @@ -39,11 +39,16 @@ public static MmsDataValue Decode(BerTlv tlv) { 1 => MmsDataValue.Array(DecodeAllData(tlv.Value)), 2 => MmsDataValue.Structure(DecodeAllData(tlv.Value)), - 3 => MmsDataValue.Boolean(BerReader.ReadBoolean(tlv) ?? false), + 3 => BerReader.ReadBoolean(tlv) is { } booleanValue + ? MmsDataValue.Boolean(booleanValue) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), 4 => DecodeBitString(tlv.Value.Span), - 5 => MmsDataValue.Integer(BerReader.ReadSignedInteger(tlv) ?? 0), - 6 => MmsDataValue.Unsigned(BerReader.ReadUnsignedInteger(tlv) ?? 0), - 7 => MmsDataValue.FloatingPoint(DecodeFloatingPoint(tlv.Value.Span)), + 5 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadSignedInteger(tlv) is { } signed + ? MmsDataValue.Integer(signed) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), + 6 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadUnsignedInteger(tlv) is { } unsigned + ? MmsDataValue.Unsigned(unsigned) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), + 7 => tlv.Value.Length is 4 or 5 + ? MmsDataValue.FloatingPoint(DecodeFloatingPoint(tlv.Value.Span)) + : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), 9 => MmsDataValue.OctetString(tlv.Value.Span), 10 => MmsDataValue.VisibleString(BerReader.ReadAsciiString(tlv)), 12 => MmsDataValue.BinaryTime(tlv.Value.Span), @@ -126,8 +131,8 @@ private static byte TagFor(MmsDataKind kind, int? unknownTagNumber) private static MmsDataValue DecodeBitString(ReadOnlySpan bytes) { - if (bytes.IsEmpty) - return MmsDataValue.BitString(0, ReadOnlySpan.Empty); + if (bytes.IsEmpty || bytes[0] > 7 || (bytes.Length == 1 && bytes[0] != 0)) + return MmsDataValue.Unknown(4, bytes); return MmsDataValue.BitString(bytes[0], bytes[1..]); } From b1682e8e2e347c3b62db52d0fade5ce1a977d899 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:23:54 +0700 Subject: [PATCH 162/176] fix(mms): enforce BER boolean payload width before typed admission --- src/AR.Iec61850/Mms/MmsDataCodec.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsDataCodec.cs b/src/AR.Iec61850/Mms/MmsDataCodec.cs index ff6ad0d5..3150b312 100644 --- a/src/AR.Iec61850/Mms/MmsDataCodec.cs +++ b/src/AR.Iec61850/Mms/MmsDataCodec.cs @@ -39,7 +39,7 @@ public static MmsDataValue Decode(BerTlv tlv) { 1 => MmsDataValue.Array(DecodeAllData(tlv.Value)), 2 => MmsDataValue.Structure(DecodeAllData(tlv.Value)), - 3 => BerReader.ReadBoolean(tlv) is { } booleanValue + 3 => tlv.Value.Length == 1 && BerReader.ReadBoolean(tlv) is { } booleanValue ? MmsDataValue.Boolean(booleanValue) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), 4 => DecodeBitString(tlv.Value.Span), 5 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadSignedInteger(tlv) is { } signed From 5dbccbce3f4b60f8fed6a57a82696544f2e26862 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:24:19 +0700 Subject: [PATCH 163/176] test(mms): malformed boolean/int/float/bitstring cannot become synthetic zero or false --- .../Mms/P0CTypedMmsPrimitiveEvidenceTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs b/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs new file mode 100644 index 00000000..81892a86 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs @@ -0,0 +1,51 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +/// Independent synthetic BER fixtures; no device-specific captured bytes. +public sealed class P0CTypedMmsPrimitiveEvidenceTests +{ + [Theory] + [InlineData("8300", 3)] + [InlineData("83020001", 3)] + [InlineData("8500", 5)] + [InlineData("8600", 6)] + [InlineData("8509000000000000000000", 5)] + [InlineData("8609000000000000000000", 6)] + [InlineData("8703000000", 7)] + [InlineData("8400", 4)] + [InlineData("840108", 4)] + public void Malformed_Primitive_Is_Unknown_Not_False_Or_Zero(string berHex, int tag) + { + var values = MmsDataCodec.DecodeAllData(Convert.FromHexString(berHex)); + var decoded = Assert.Single(values); + Assert.Equal(MmsDataKind.Unknown, decoded.Kind); + Assert.Equal(tag, decoded.UnknownTagNumber); + } + + [Fact] + public void Valid_Zero_And_False_Are_Real_Observed_Typed_Values() + { + var encoded = MmsDataCodec.EncodeAllData([ + MmsDataValue.Boolean(false), MmsDataValue.Integer(0), + MmsDataValue.Unsigned(0), MmsDataValue.Integer(-3), + MmsDataValue.Unsigned(4294967295)]); + var decoded = MmsDataCodec.DecodeAllData(encoded); + Assert.Equal(false, decoded[0].Value); + Assert.Equal(0L, decoded[1].Value); + Assert.Equal(0UL, decoded[2].Value); + Assert.Equal(-3L, decoded[3].Value); + Assert.Equal(4294967295UL, decoded[4].Value); + Assert.All(decoded, x => Assert.NotEqual(MmsDataKind.Unknown, x.Kind)); + } + + [Fact] + public void Raw_Unknown_Wire_Kind_Never_Produces_A_Projected_Value() + { + var unknown = Assert.Single(MmsDataCodec.DecodeAllData( + Convert.FromHexString("8500"))); + Assert.Equal(MmsDataKind.Unknown, unknown.Kind); + Assert.Equal("85", MmsDataCodec.Encode(unknown).Length > 0 ? + Convert.ToHexString(MmsDataCodec.Encode(unknown))[..2] : ""); + } +} From 91894b3e1b0bfbe0b0385c0ad4e1b2651435b77a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:24:57 +0700 Subject: [PATCH 164/176] feat(mms): typed access-failure taxonomy distinct from read result payload --- .../Mms/MmsReadEvidenceClassifier.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs diff --git a/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs b/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs new file mode 100644 index 00000000..05a53662 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs @@ -0,0 +1,32 @@ +namespace AR.Iec61850.Mms; + +/// +/// Evidence classification is orthogonal to MMS Data type: a failed access +/// never becomes a scalar, including when an expected SCL type exists. +/// +public enum MmsReadEvidenceFailureKind +{ + None, + AccessDenied, + NotSupported, + TypeMismatch, + ObjectMissing, + TemporarilyUnavailable, + RemoteAccessFailure, + InvalidResponse, + ProtocolRejected +} + +public static class MmsReadEvidenceClassifier +{ + public static MmsReadEvidenceFailureKind FromAccessCode(int? code) + => code switch + { + 2 => MmsReadEvidenceFailureKind.TemporarilyUnavailable, + 3 => MmsReadEvidenceFailureKind.AccessDenied, + 4 or 5 or 10 => MmsReadEvidenceFailureKind.ObjectMissing, + 6 or 9 => MmsReadEvidenceFailureKind.NotSupported, + 7 or 8 => MmsReadEvidenceFailureKind.TypeMismatch, + _ => MmsReadEvidenceFailureKind.RemoteAccessFailure + }; +} From 1c8e10503fec18934d780ce8c6258374d9ce325f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:25:36 +0700 Subject: [PATCH 165/176] feat(mms): propagate typed single read failure evidence without synthetic values --- src/AR.Iec61850/Mms/MmsRead.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsRead.cs b/src/AR.Iec61850/Mms/MmsRead.cs index 554f5179..fbf1cf4a 100644 --- a/src/AR.Iec61850/Mms/MmsRead.cs +++ b/src/AR.Iec61850/Mms/MmsRead.cs @@ -16,6 +16,7 @@ public sealed class MmsReadResult public bool IsSuccess { get; init; } public MmsDataValue? Value { get; init; } public int? FailureCode { get; init; } + public MmsReadEvidenceFailureKind FailureKind { get; init; } public string Message { get; init; } = string.Empty; public string ResponseHexPreview { get; init; } = string.Empty; } @@ -93,10 +94,10 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati return Fail("Empty MMS response payload.", hex); if (mms[0] == 0xA2) - return Fail($"MMS Confirmed-Error PDU received: {HexDump.ToCompactString(mms)}", hex); + return Fail($"MMS Confirmed-Error PDU received: {HexDump.ToCompactString(mms)}", hex, kind: MmsReadEvidenceFailureKind.ProtocolRejected); if (mms[0] == 0xA3 || mms[0] == 0xA4) - return Fail($"MMS Reject/Abort PDU received: {HexDump.ToCompactString(mms)}", hex); + return Fail($"MMS Reject/Abort PDU received: {HexDump.ToCompactString(mms)}", hex, kind: MmsReadEvidenceFailureKind.ProtocolRejected); if (!TryValidateConfirmedResponse(mms, expectedInvokeId, out var message)) return Fail(message, hex); @@ -113,7 +114,9 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati } if (!string.IsNullOrWhiteSpace(accessMessage)) - return Fail(accessMessage, hex, failureCode); + return Fail(accessMessage, hex, failureCode, failureCode.HasValue + ? MmsReadEvidenceClassifier.FromAccessCode(failureCode) + : MmsReadEvidenceFailureKind.InvalidResponse); // Legacy fallback for tolerant decoding of non-standard/short responses. var values = new List(); @@ -123,7 +126,8 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati if (failure.EncodedTag == 0x80) { var code = BerReader.ReadUnsignedInteger(failure); - return Fail(FormatDataAccessFailure(code), hex, code.HasValue ? (int)code.Value : null); + return Fail(FormatDataAccessFailure(code), hex, code.HasValue ? (int)code.Value : null, + MmsReadEvidenceClassifier.FromAccessCode(code.HasValue ? (int)code.Value : null)); } foreach (var tlv in values.AsEnumerable().Reverse()) @@ -365,12 +369,14 @@ private static string DataAccessErrorName(ulong code) _ => "unknown" }; - private static MmsReadResult Fail(string message, string hex, int? failureCode = null) + private static MmsReadResult Fail(string message, string hex, int? failureCode = null, + MmsReadEvidenceFailureKind kind = MmsReadEvidenceFailureKind.InvalidResponse) { return new MmsReadResult { IsSuccess = false, FailureCode = failureCode, + FailureKind = kind, Message = message, ResponseHexPreview = hex }; From e58f33ca3a4d6902c76da8f87d2ffafc1a393f1c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:25:40 +0700 Subject: [PATCH 166/176] feat(mms): propagate typed ordered batch read failure evidence without synthetic values --- src/AR.Iec61850/Mms/MmsReadBatch.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReadBatch.cs b/src/AR.Iec61850/Mms/MmsReadBatch.cs index ccfbc03e..d6e80869 100644 --- a/src/AR.Iec61850/Mms/MmsReadBatch.cs +++ b/src/AR.Iec61850/Mms/MmsReadBatch.cs @@ -9,6 +9,7 @@ public sealed class MmsReadAccessResult public bool IsSuccess { get; init; } public MmsDataValue? Value { get; init; } public int? FailureCode { get; init; } + public MmsReadEvidenceFailureKind FailureKind { get; init; } public string Message { get; init; } = string.Empty; } @@ -133,6 +134,7 @@ public static MmsReadBatchResult DecodeResponse( { Reference = references[index], IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = $"MMS Read response ended before access result {index}." }); continue; @@ -166,6 +168,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = false, FailureCode = code.HasValue ? (int)code.Value : null, + FailureKind = MmsReadEvidenceClassifier.FromAccessCode(code.HasValue ? (int)code.Value : null), Message = code.HasValue ? $"MMS Read AccessResult.failure code {code.Value}." : "MMS Read AccessResult.failure code is undecodable." @@ -180,6 +183,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = value.Kind != MmsDataKind.Unknown, Value = value.Kind == MmsDataKind.Unknown ? null : value, + FailureKind = value.Kind == MmsDataKind.Unknown ? MmsReadEvidenceFailureKind.InvalidResponse : MmsReadEvidenceFailureKind.None, Message = value.Kind == MmsDataKind.Unknown ? "MMS Data value is unknown." : "MMS Data value decoded." }; } @@ -195,6 +199,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = value.Kind != MmsDataKind.Unknown, Value = value.Kind == MmsDataKind.Unknown ? null : value, + FailureKind = value.Kind == MmsDataKind.Unknown ? MmsReadEvidenceFailureKind.InvalidResponse : MmsReadEvidenceFailureKind.None, Message = value.Kind == MmsDataKind.Unknown ? "Wrapped MMS Data value is unknown." : "Wrapped MMS Data value decoded." }; } @@ -204,6 +209,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen { Reference = reference, IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = $"Unsupported MMS AccessResult tag 0x{accessResult.EncodedTag:X2}." }; } @@ -231,6 +237,7 @@ private static MmsReadBatchResult FailAll( { Reference = reference, IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = message }).ToArray(), Message = message, From 186b84414d5180d3c8f01212ea00f74f30e2c8d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:26:54 +0700 Subject: [PATCH 167/176] fix(mms): quarantine entire batch when access-result ordinal count mismatches --- src/AR.Iec61850/Mms/MmsReadBatch.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsReadBatch.cs b/src/AR.Iec61850/Mms/MmsReadBatch.cs index d6e80869..08862542 100644 --- a/src/AR.Iec61850/Mms/MmsReadBatch.cs +++ b/src/AR.Iec61850/Mms/MmsReadBatch.cs @@ -125,6 +125,14 @@ public static MmsReadBatchResult DecodeResponse( return FailAll(references, "MMS Read response has no listOfAccessResult.", hex); var accessResults = BerReader.ReadChildren(accessList.Value); + // A Read response with missing/extra access results cannot be + // safely joined to requested FC tuples by ordinal. Retain the + // mismatch as failure evidence instead of accepting a prefix. + if (accessResults.Count != references.Count) + return FailAll(references, + $"MMS Read AccessResult count mismatch: requested={references.Count}, received={accessResults.Count}.", + hex, Math.Max(0, accessResults.Count - references.Count)); + var results = new List(references.Count); for (var index = 0; index < references.Count; index++) { @@ -230,9 +238,11 @@ private static bool IsMmsData(BerTlv node) private static MmsReadBatchResult FailAll( IReadOnlyList references, string message, - string hex) + string hex, + int extraAccessResultCount = 0) => new() { + ExtraAccessResultCount = extraAccessResultCount, Results = references.Select(reference => new MmsReadAccessResult { Reference = reference, From 1414948c07d30e04b74ebd7a4bfb08b0fade6383 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:27:13 +0700 Subject: [PATCH 168/176] ci(p0-c): run full Windows engine gate on P0-B-stacked typed read branch --- .github/workflows/dotnet-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 967b0a7d..c20e5cc6 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -22,7 +22,7 @@ on: pull_request: # 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 ] + branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe, fix/p0-b-target-scoped-rcb-pager-20261019 ] workflow_dispatch: permissions: From ba21d882f67de22c10d3155b449aac77aa8b4899 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:27:51 +0700 Subject: [PATCH 169/176] test(p0-c): typed per-access MMS Read failure, invoke fence, strict ordinal and valid false/zero --- .../Mms/P0CTypedReadAccessResultTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs b/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs new file mode 100644 index 00000000..026be7b4 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs @@ -0,0 +1,86 @@ +using AR.Iec61850.Asn1; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class P0CTypedReadAccessResultTests +{ + [Fact] + public void Ordered_Confirmed_Read_Maintains_Int32_Zero_And_Individual_AccessFailures() + { + var references = Enumerable.Range(0, 4) + .Select(i => new MmsObjectReference("LD0", $"LLN0$SG$Value{i}$setVal", "SG")) + .ToArray(); + var response = Response(42, + MmsDataCodec.Encode(MmsDataValue.Integer(0)), + BerWriter.EncodeTlv(0x80, new byte[] { 3 }), + BerWriter.EncodeTlv(0x80, new byte[] { 6 }), + BerWriter.EncodeTlv(0x80, new byte[] { 7 })); + var batch = MmsReadBatchCodec.DecodeResponse(response, references, expectedInvokeId: 42); + Assert.Equal(4, batch.Results.Count); + Assert.Equal(0L, batch.Results[0].Value!.Value); + Assert.True(batch.Results[0].IsSuccess); + Assert.Equal(MmsReadEvidenceFailureKind.None, batch.Results[0].FailureKind); + Assert.Equal(MmsReadEvidenceFailureKind.AccessDenied, batch.Results[1].FailureKind); + Assert.Equal(3, batch.Results[1].FailureCode); + Assert.Equal(MmsReadEvidenceFailureKind.NotSupported, batch.Results[2].FailureKind); + Assert.Equal(MmsReadEvidenceFailureKind.TypeMismatch, batch.Results[3].FailureKind); + Assert.All(batch.Results.Skip(1), x => Assert.Null(x.Value)); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + public void Missing_Or_Extra_AccessResults_Fail_All_Without_Ordinal_Ambiguity(int resultCount) + { + var references = new[] + { + new MmsObjectReference("LD0", "LLN0$SG$PTOCEna$setVal", "SG"), + new MmsObjectReference("LD0", "LLN0$SG$InMagTyp$setVal", "SG") + }; + var response = Response(5, Enumerable.Range(0, resultCount) + .Select(_ => MmsDataCodec.Encode(MmsDataValue.Boolean(false))).ToArray()); + var result = MmsReadBatchCodec.DecodeResponse(response, references, expectedInvokeId: 5); + Assert.False(result.HasAnySuccess); + Assert.All(result.Results, x => + { + Assert.False(x.IsSuccess); + Assert.Null(x.Value); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, x.FailureKind); + }); + Assert.Contains("count mismatch", result.Message); + } + + [Fact] + public void Stale_Invoke_Cannot_Publish_Even_When_Data_Is_Decodable() + { + var refs = new[] {new MmsObjectReference("LD0", "LLN0$SG$PTOCEna$setVal", "SG")}; + var result = MmsReadBatchCodec.DecodeResponse( + Response(21, MmsDataCodec.Encode(MmsDataValue.Boolean(false))), refs, + expectedInvokeId: 22); + Assert.False(result.HasAnySuccess); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, + Assert.Single(result.Results).FailureKind); + } + + [Fact] + public void Invalid_Ber_Integer_Leaves_Unknown_Not_Observed_Zero() + { + var refs = new[] {new MmsObjectReference("LD0", "LLN0$SG$InMagTyp$setVal", "SG")}; + var result = MmsReadBatchCodec.DecodeResponse( + Response(42, Convert.FromHexString("8500")), refs, expectedInvokeId: 42); + var item = Assert.Single(result.Results); + Assert.False(item.IsSuccess); + Assert.Null(item.Value); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, item.FailureKind); + } + + private static byte[] Response(int invokeId, params byte[][] accessResults) + { + var results = accessResults.SelectMany(item => item).ToArray(); + var service = BerWriter.EncodeTlv(0xA4, + BerWriter.EncodeTlv(0xA1, results)); + var invoke = BerWriter.EncodeTlv(0x02, BerWriter.EncodeUnsignedInteger((ulong)invokeId)); + return BerWriter.EncodeTlv(0xA1, invoke.Concat(service).ToArray()); + } +} From c85d4e20db722bd0c2729f2533b323b13d3ef735 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:35:19 +0700 Subject: [PATCH 170/176] fix: isolate RCB reports from control indications and expose control stage timing --- CHANGELOG.md | 6 +++ .../Control/Iec61850ControlObjectSession.cs | 31 +++++++++--- .../Mms/MmsPersistentReportMonitor.cs | 11 +++++ .../Control/G1OrderedWireEvidenceTests.cs | 8 ++++ .../Mms/MmsPersistentReportRoutingTests.cs | 47 +++++++++++++++++++ 5 files changed, 97 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a1089e..2895c155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Control/report isolation trial — 2026-10-20 + +- Reject non-RCB InformationReport shapes before persistent single-monitor fallback; control termination and LastApplError remain available to independent control subscribers. +- Include monotonic service duration and post-response wait in each control wire-step detail. UTC marks local service start, not wire send. This separates SBOw/Operate service latency from asynchronous completion wait without changing timeout, checks, or command sequencing. +- Synthetic regression coverage added; physical latency improvement is not yet established. + All notable public changes to ARIEC61850 are recorded here. The project is still evolving toward stable semantic releases; entries are grouped by engineering milestone until a formal versioning policy is adopted. ## Unreleased — public wording, provenance and release assurance diff --git a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs index 9550388e..1018c50b 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs @@ -27,6 +27,10 @@ private sealed class AssociationControlState private CancellationTokenSource? _selectionLeaseCts; private bool _ownsLock; private bool _disposed; + private DateTimeOffset _serviceStartedUtc; + private TimeSpan _serviceElapsed; + private long _serviceCompletedTimestamp; + internal Iec61850ControlObjectSession( IIec61850ControlTransport transport, @@ -115,7 +119,7 @@ private async Task SelectCoreAsync( try { var context = CreateContext(request); - var read = await _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken).ConfigureAwait(false); + var read = await MeasureServiceAsync(() => _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken)).ConfigureAwait(false); if (!read.IsSuccess || !IsPositiveSboSelection(read.Value, Descriptor.References)) { ReleaseObject(); @@ -172,7 +176,7 @@ private async Task SelectWithValueCoreAsync( context, Descriptor.SelectWithValueSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken)).ConfigureAwait(false); if (!write.IsSuccess) { var appError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false); @@ -289,7 +293,7 @@ private async Task OperateCoreAsync( context, Descriptor.OperSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token)).ConfigureAwait(false); if (!write.IsSuccess) { var applicationError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false); @@ -432,7 +436,7 @@ private async Task CancelCoreAsync(CancellationToke return Unsupported(Iec61850ControlAction.Cancel, "IED did not expose a decodable Cancel type specification."); var value = Iec61850ControlStructureBuilder.BuildCancel(context, Descriptor.CancelSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false); return write.IsSuccess ? Accepted(Iec61850ControlAction.Cancel, context, stopwatch.Elapsed, "Cancel accepted; local SBO ownership released.") : FromWriteFailure(Iec61850ControlAction.Cancel, write, null, context, stopwatch.Elapsed); @@ -691,7 +695,7 @@ private async Task BestEffortCancelAsync(CancellationToken cancellationToken) context, Descriptor.CancelSpecification, _options.RequireExactNamedControlFields); - await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false); + await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false); } } catch @@ -782,6 +786,20 @@ private Iec61850ControlActionResult FromApplicationRejection( BuildWireStep(action, requestAccepted: true, clientMessage)) }; + // Serialized by _operationGate. These are local service boundaries, not + // packet capture timestamps; elapsed measurement uses the monotonic clock. + private async Task MeasureServiceAsync(Func> service) + { + _serviceStartedUtc = DateTimeOffset.UtcNow; + var started = Stopwatch.GetTimestamp(); + try { return await service().ConfigureAwait(false); } + finally + { + _serviceElapsed = Stopwatch.GetElapsedTime(started); + _serviceCompletedTimestamp = Stopwatch.GetTimestamp(); + } + } + private Iec61850ControlWireStep BuildWireStep( Iec61850ControlAction action, bool requestAccepted, @@ -803,7 +821,8 @@ private Iec61850ControlWireStep BuildWireStep( RequestAccepted = requestAccepted, RequestHex = _transport.LastRequestHex, ResponseHex = _transport.LastResponseHex, - Detail = detail + Detail = FormattableString.Invariant( + $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; timingScope=local-service-not-wire") }; } diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index d5ec0490..387a415f 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -637,6 +637,12 @@ private void RoutePersistentInformationReport(byte[] payload) return; } + // Control termination / LastApplError use the same MMS service but are + // not RCB reports. Never feed them to the single-monitor fallback. + // Their independent control subscribers already received the envelope. + if (!HasRcbReportHeader(decoded)) + return; + var header = MmsReportFrameMapper.DecodeHeader(decoded); var active = SnapshotPersistentReportMonitors(); var target = SelectPersistentReportMonitor(active, header, out var evidence); @@ -655,6 +661,11 @@ private void RoutePersistentInformationReport(byte[] payload) $"Routed InformationReport to {target.ReportControl.Reference} by {evidence}. RptID={RoutingTextOrDash(header.ReportId)}, DatSet={RoutingTextOrDash(header.DataSetReference)}."; } + internal static bool HasRcbReportHeader(MmsInformationReport report) + => report.IsSuccess && report.Items.Count >= 2 && + report.Items[0].Value?.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString && + report.Items[1].Value?.Kind == MmsDataKind.BitString; + internal static MmsPersistentReportMonitorSession? SelectPersistentReportMonitor( IReadOnlyList active, MmsReportHeader header, diff --git a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs index 856f2397..6482b3ee 100644 --- a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs +++ b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs @@ -69,6 +69,14 @@ public async Task SboEnhanced_PositiveTermination_PreservesSboWThenOperateWireSt Assert.True(result.WireSteps[1].RequestAccepted); Assert.Contains("positive CommandTermination", result.WireSteps[1].Detail, StringComparison.OrdinalIgnoreCase); + foreach (var step in result.WireSteps) + { + Assert.Contains("serviceStartedUtc=", step.Detail); + Assert.Contains("serviceMs=", step.Detail); + Assert.Contains("postResponseWaitMs=", step.Detail); + Assert.Contains("timingScope=local-service-not-wire", step.Detail); + } + Assert.Equal(2, transport.Writes.Count); Assert.EndsWith("$SBOw", transport.Writes[0].Reference.Item, StringComparison.Ordinal); Assert.EndsWith("$Oper", transport.Writes[1].Reference.Item, StringComparison.Ordinal); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs index 2fccfc4b..34f7494c 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs @@ -4,6 +4,53 @@ namespace AR.Iec61850.Tests.Mms; public sealed class MmsPersistentReportRoutingTests { + // Synthetic typed MMS evidence: no external packet capture or station data. + [Fact] + public void ControlInformationReportsCannotEnterSingleRcbFallback() + { + foreach (var value in new[] + { + MmsDataValue.Boolean(true), + MmsDataValue.Structure(new[] { MmsDataValue.VisibleString("LD0/CSWI1$CO$Pos$Oper"), MmsDataValue.Integer(0) }) + }) + { + var report = new MmsInformationReport + { + IsSuccess = true, + VariableReferences = new[] { "LD0/CSWI1$CO$Pos$Oper" }, + Items = new[] { new MmsInformationReportItem { Value = value } } + }; + Assert.False(MmsClientSession.HasRcbReportHeader(report)); + } + } + + [Fact] + public void RcbHeaderDoesNotRequireOptionalSequenceOrDatasetFields() + { + var report = new MmsInformationReport + { + IsSuccess = true, + Items = new[] + { + new MmsInformationReportItem { Value = MmsDataValue.VisibleString("RPT_A") }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(6, new byte[] { 0, 0 }) }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(7, new byte[] { 0x80 }) }, + new MmsInformationReportItem { Value = MmsDataValue.Boolean(true) } + } + }; + Assert.True(MmsClientSession.HasRcbReportHeader(report)); + Assert.False(MmsClientSession.HasRcbReportHeader(new MmsInformationReport())); + Assert.False(MmsClientSession.HasRcbReportHeader(new MmsInformationReport + { + IsSuccess = true, + Items = new[] + { + new MmsInformationReportItem { Value = MmsDataValue.Boolean(true) }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(6, new byte[] { 0, 0 }) } + } + })); + } + [Fact] public void Selector_RoutesByExactReportIdBeforeDataSetProjection() { From 3aa0cafd958a4307f93512d201d7972cfee1b147 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:04:45 +0700 Subject: [PATCH 171/176] feat(p0-c): attribute SBOw latency to negative-indication observation gate without reducing safety --- .../Control/Iec61850ControlObjectSession.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs index 1018c50b..1bd7b268 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs @@ -814,6 +814,19 @@ private Iec61850ControlWireStep BuildWireStep( _ => Descriptor.References.Oper }; + // Distinguish protocol service response time from the selection + // negative-indication observation gate. The latter dominates the + // field sample (~400 ms); it must not be misreported as slow MMS I/O. + // This is telemetry only: do not reduce the grace period or bypass + // LastApplError and enhanced-security CommandTermination. + var postResponseStage = action switch + { + Iec61850ControlAction.SelectWithValue => "sbo-application-error-observation", + Iec61850ControlAction.Operate => "operate-termination-observation", + _ => "post-service-completion" + }; + var graceBudget = action == Iec61850ControlAction.SelectWithValue + ? _options.ApplicationErrorGracePeriod.TotalMilliseconds : 0d; return new Iec61850ControlWireStep { Action = action, @@ -822,7 +835,7 @@ private Iec61850ControlWireStep BuildWireStep( RequestHex = _transport.LastRequestHex, ResponseHex = _transport.LastResponseHex, Detail = FormattableString.Invariant( - $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; timingScope=local-service-not-wire") + $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; postResponseStage={postResponseStage}; configuredApplicationErrorGraceMs={graceBudget:0.###}; timingScope=local-service-not-wire") }; } From 9c7cb40364023a7b217e5ec31ab3105ba90142eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:05:37 +0700 Subject: [PATCH 172/176] test(p0-c): reject immediate and delayed SBOw LastApplError without sending Operate --- .../Control/G1OrderedWireEvidenceTests.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs index 6482b3ee..4071d179 100644 --- a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs +++ b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs @@ -74,6 +74,8 @@ public async Task SboEnhanced_PositiveTermination_PreservesSboWThenOperateWireSt Assert.Contains("serviceStartedUtc=", step.Detail); Assert.Contains("serviceMs=", step.Detail); Assert.Contains("postResponseWaitMs=", step.Detail); + Assert.Contains("postResponseStage=", step.Detail); + Assert.Contains("configuredApplicationErrorGraceMs=", step.Detail); Assert.Contains("timingScope=local-service-not-wire", step.Detail); } @@ -82,6 +84,79 @@ public async Task SboEnhanced_PositiveTermination_PreservesSboWThenOperateWireSt Assert.EndsWith("$Oper", transport.Writes[1].Reference.Item, StringComparison.Ordinal); } + [Theory] + [InlineData(0)] + [InlineData(35)] + public async Task SboEnhanced_AsyncNegativeApplicationError_QuarantinesOperate( + int rejectionDelayMs) + { + // Synthetic independently constructed MMS indication; deliberately + // arrives AFTER successful SBOw service acceptance. A faster UI must + // not bypass this asynchronous rejection or emit the Operate write. + var transport = new WireEvidenceTransport(); + Task? rejection = null; + transport.OnWrite = (reference, _) => + { + if (!reference.Item.EndsWith("$SBOw", StringComparison.Ordinal)) + return; + rejection = Task.Run(async () => + { + await Task.Delay(rejectionDelayMs); + transport.EmitReport(reference, MmsDataValue.Structure( + [ + MmsDataValue.VisibleString("LD0/CSWI1.Pos"), + MmsDataValue.Integer(1), // control-error + MmsDataValue.Integer(10) // blocked-by-interlocking + ])); + }); + }; + + var references = Iec61850ControlObjectReferences.Parse("LD0/CSWI1.Pos"); + await using var session = new Iec61850ControlObjectSession( + transport, + new Iec61850ControlObjectDescriptor + { + ObjectReference = references.ObjectReference, + Cdc = "DPC", + ControlModel = Iec61850ControlModel.SelectBeforeOperateEnhanced, + CtlValSpecification = Type("ctlVal", "bit-string", 2), + OperSpecification = CommandSpecification(), + SelectWithValueSpecification = CommandSpecification(), + CancelSpecification = CommandSpecification(), + StatusReference = "LD0/CSWI1.Pos.stVal", + StatusFunctionalConstraint = "ST", + SboTimeout = TimeSpan.FromSeconds(2), + OperTimeout = TimeSpan.FromMilliseconds(250), + SupportsCommandTermination = true, + References = references + }, + new Iec61850ControlServiceOptions + { + DefaultOperateTimeout = TimeSpan.FromMilliseconds(250), + ApplicationErrorGracePeriod = TimeSpan.FromMilliseconds(120) + }); + var result = await session.OperateAsync(new Iec61850ControlRequest + { + ControlValue = Iec61850ControlValue.Close(), + Origin = Iec61850Origin.FromText("ARG1"), + InterlockCheck = true, + AutoSelect = true, + CommandTerminationTimeout = TimeSpan.FromMilliseconds(250) + }); + if (rejection is not null) + await rejection; + + Assert.Equal(Iec61850ControlCompletionState.Rejected, result.CompletionState); + Assert.True(result.RequestAccepted); // SBOw service did respond positively + Assert.False(result.CommandTerminationReceived); + Assert.Equal("blocked-by-interlocking", result.AddCause); + Assert.Single(transport.Writes); // no Operate, even with a delayed rejection + Assert.EndsWith("$SBOw", transport.Writes[0].Reference.Item, StringComparison.Ordinal); + var wire = Assert.Single(result.WireSteps); + Assert.Contains("postResponseStage=sbo-application-error-observation", wire.Detail); + Assert.Contains("configuredApplicationErrorGraceMs=120", wire.Detail); + } + private static MmsTypeSpecificationNode CommandSpecification() => Structure(string.Empty, Type("ctlVal", "bit-string", 2), From f2b18df8763b166275c2338941170d314acf62b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:37:47 +0700 Subject: [PATCH 173/176] feat(control-telemetry): measure exact confirmed MMS send, response wait and decode without altering command semantics --- src/AR.Iec61850/Mms/MmsWrite.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsWrite.cs b/src/AR.Iec61850/Mms/MmsWrite.cs index 4b23ba63..80df52e6 100644 --- a/src/AR.Iec61850/Mms/MmsWrite.cs +++ b/src/AR.Iec61850/Mms/MmsWrite.cs @@ -17,6 +17,12 @@ public sealed class MmsWriteResult public IReadOnlyList AccessResults { get; init; } = Array.Empty(); public string Message { get; init; } = string.Empty; public string ResponseHexPreview { get; init; } = string.Empty; + + // Time inside the client process, not on-the-wire latency. Populated + // only when this write has a confirmed MMS response. + public TimeSpan? TpktSendElapsed { get; internal set; } + public TimeSpan? ConfirmedResponseWaitElapsed { get; internal set; } + public TimeSpan? ResponseDecodeElapsed { get; internal set; } } public static class MmsWriteRequest From 3850e7ba64460ac8e28c40a633ce0f0422a9a486 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:37:50 +0700 Subject: [PATCH 174/176] feat(control-telemetry): measure exact confirmed MMS send, response wait and decode without altering command semantics --- src/AR.Iec61850/Mms/MmsClientSession.cs | 42 +++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsClientSession.cs b/src/AR.Iec61850/Mms/MmsClientSession.cs index 0ca63d33..d783b5e7 100644 --- a/src/AR.Iec61850/Mms/MmsClientSession.cs +++ b/src/AR.Iec61850/Mms/MmsClientSession.cs @@ -1,6 +1,7 @@ using AR.Iec61850.Acse; using AR.Iec61850.Diagnostics; using AR.Iec61850.Osi; +using System.Diagnostics; namespace AR.Iec61850.Mms; @@ -561,8 +562,17 @@ private async Task WriteSingleVariableCoreAsync( try { - var response = await SendConfirmedPresentationPayloadAsync(request, invokeId, cancellationToken).ConfigureAwait(false); + TimeSpan? sendDuration = null; + TimeSpan? responseDuration = null; + var response = await SendConfirmedPresentationPayloadAsync( + request, invokeId, cancellationToken, + (send, wait) => { sendDuration = send; responseDuration = wait; }) + .ConfigureAwait(false); + var decodeStart = Stopwatch.GetTimestamp(); var result = MmsWriteResponseDecoder.Decode(response, invokeId); + result.TpktSendElapsed = sendDuration; + result.ConfirmedResponseWaitElapsed = responseDuration; + result.ResponseDecodeElapsed = Stopwatch.GetElapsedTime(decodeStart); LastReadResponseHex = result.ResponseHexPreview; return result; } @@ -659,21 +669,43 @@ public async Task SendPresentationPayloadAsync(ReadOnlyMemory payl private async Task SendConfirmedPresentationPayloadAsync( ReadOnlyMemory payload, int expectedInvokeId, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Action? onCompletedTiming = null) { if (!IsTransportConnected) throw new InvalidOperationException("Native IEC 61850 transport is not connected."); + // No global mutable timestamps: the closure and invoke ID belong to this + // one request. Concurrent confirmed reads cannot overwrite its evidence. + // The send measurement includes the local TPKT send gate and stream write. + // Response-wait begins AFTER the completed local send; it is NOT pure + // wire RTT, nor does it isolate remote IED decision time. + var sendStarted = onCompletedTiming is null ? 0L : Stopwatch.GetTimestamp(); + long sentAt = 0L; + void CaptureCompleteTiming() + { + if (onCompletedTiming is null || sentAt == 0) + return; + onCompletedTiming( + Stopwatch.GetElapsedTime(sendStarted, sentAt), + Stopwatch.GetElapsedTime(sentAt)); + } + if (_receivePump.IsRunning) { using var pending = _receivePump.RegisterConfirmedOperation(expectedInvokeId); await _cotp.SendDataAsync(payload, cancellationToken).ConfigureAwait(false); + if (onCompletedTiming is not null) + sentAt = Stopwatch.GetTimestamp(); var envelope = await pending.WaitAsync(cancellationToken).ConfigureAwait(false); + CaptureCompleteTiming(); LastReceiveRoutingSummary = $"Receive pump completed {envelope.Kind} for invokeID={expectedInvokeId}. queuedReports={_receivePump.QueuedInformationReportCount}."; return envelope.PresentationPayload; } await _cotp.SendDataAsync(payload, cancellationToken).ConfigureAwait(false); + if (onCompletedTiming is not null) + sentAt = Stopwatch.GetTimestamp(); while (true) { @@ -681,6 +713,7 @@ private async Task SendConfirmedPresentationPayloadAsync( if (_receiveRouter.TryDequeueConfirmedResult(expectedInvokeId, out var queued)) { + CaptureCompleteTiming(); LastReceiveRoutingSummary = $"Dequeued queued {queued.Kind} for invokeID={expectedInvokeId}."; return queued.PresentationPayload; } @@ -696,11 +729,14 @@ private async Task SendConfirmedPresentationPayloadAsync( if (route.Action == MmsReceiveRouteAction.QueuedConfirmedResult) { if (_receiveRouter.TryDequeueConfirmedResult(expectedInvokeId, out var matched)) + { + CaptureCompleteTiming(); return matched.PresentationPayload; - + } continue; } + CaptureCompleteTiming(); return route.Envelope.PresentationPayload; } } From 695df4e60cc96c296a51b3d210dc0ea073d3a3a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:37:53 +0700 Subject: [PATCH 175/176] feat(control-telemetry): measure exact confirmed MMS send, response wait and decode without altering command semantics --- .../Control/Iec61850ControlObjectSession.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs index 1bd7b268..55f911f3 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs @@ -30,6 +30,7 @@ private sealed class AssociationControlState private DateTimeOffset _serviceStartedUtc; private TimeSpan _serviceElapsed; private long _serviceCompletedTimestamp; + private MmsWriteResult? _lastMeasuredWrite; internal Iec61850ControlObjectSession( @@ -792,7 +793,16 @@ private async Task MeasureServiceAsync(Func> service) { _serviceStartedUtc = DateTimeOffset.UtcNow; var started = Stopwatch.GetTimestamp(); - try { return await service().ConfigureAwait(false); } + _lastMeasuredWrite = null; + try + { + var result = await service().ConfigureAwait(false); + // Only the exact MMS write result which produced this step can + // supply transport timing. Never borrow another invoke ID's stats. + if (result is MmsWriteResult write) + _lastMeasuredWrite = write; + return result; + } finally { _serviceElapsed = Stopwatch.GetElapsedTime(started); @@ -827,6 +837,12 @@ private Iec61850ControlWireStep BuildWireStep( }; var graceBudget = action == Iec61850ControlAction.SelectWithValue ? _options.ApplicationErrorGracePeriod.TotalMilliseconds : 0d; + var transportStages = _lastMeasuredWrite is + { TpktSendElapsed: { } send, ConfirmedResponseWaitElapsed: { } response, + ResponseDecodeElapsed: { } decode } + ? FormattableString.Invariant( + $"tpktSendMs={send.TotalMilliseconds:0.###}; confirmedResponseWaitMs={response.TotalMilliseconds:0.###}; mmsDecodeMs={decode.TotalMilliseconds:0.###}") + : "transportStages=unavailable"; return new Iec61850ControlWireStep { Action = action, @@ -835,7 +851,7 @@ private Iec61850ControlWireStep BuildWireStep( RequestHex = _transport.LastRequestHex, ResponseHex = _transport.LastResponseHex, Detail = FormattableString.Invariant( - $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; postResponseStage={postResponseStage}; configuredApplicationErrorGraceMs={graceBudget:0.###}; timingScope=local-service-not-wire") + $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; postResponseStage={postResponseStage}; configuredApplicationErrorGraceMs={graceBudget:0.###}; {transportStages}; timingScope=local-service-not-wire") }; } From 4ed76ccd11b763dcd847395f6ef45273db3f5c9c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:38:50 +0700 Subject: [PATCH 176/176] test(control): pin per-invoke opt-in send/confirmed-response stages and unchanged enhanced guards --- .../Control/P0SboServiceWireStagesTests.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Control/P0SboServiceWireStagesTests.cs diff --git a/tests/AR.Iec61850.Tests/Control/P0SboServiceWireStagesTests.cs b/tests/AR.Iec61850.Tests/Control/P0SboServiceWireStagesTests.cs new file mode 100644 index 00000000..faad0d88 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Control/P0SboServiceWireStagesTests.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Control; + +/// +/// Mechanical and data-contract guards for the opt-in client-side phase timer. +/// These tests do NOT simulate breaker operation or claim measured wire RTT. +/// +public sealed class P0SboServiceWireStagesTests +{ + [Fact] + public void MmsWriteResult_ExposesOptionalPerRequestTimingsWithoutFabricatedZero() + { + var failed = new MmsWriteResult + { + IsSuccess = false, + Message = "simulated timeout before response" + }; + Assert.Null(failed.TpktSendElapsed); + Assert.Null(failed.ConfirmedResponseWaitElapsed); + Assert.Null(failed.ResponseDecodeElapsed); + } + + [Fact] + public void ConfirmedMmsTimer_IsPerInvokeOptInNotGlobalSessionStopwatch() + { + var method = typeof(MmsClientSession).GetMethod( + "SendConfirmedPresentationPayloadAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + var parameters = method.GetParameters(); + Assert.Equal(4, parameters.Length); + Assert.Equal(typeof(int), parameters[1].ParameterType); + Assert.Equal(typeof(CancellationToken), parameters[2].ParameterType); + Assert.Equal(typeof(Action), parameters[3].ParameterType); + Assert.True(parameters[3].IsOptional); + } + + [Fact] + public void TelemetryCannotTurnOffLastApplErrorOrPositiveTermination() + { + var session = ReadRepoFile("src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs"); + var transport = ReadRepoFile("src/AR.Iec61850/Mms/MmsClientSession.cs"); + var tcp = ReadRepoFile("src/AR.Iec61850/Osi/TpktClient.cs"); + + Assert.Contains("WaitForApplicationErrorAsync(", session, StringComparison.Ordinal); + Assert.Contains("_options.ApplicationErrorGracePeriod", session, StringComparison.Ordinal); + Assert.Contains("WaitForTerminationAsync(reports, remaining, cancellationToken)", session, + StringComparison.Ordinal); + Assert.Contains("tpktSendMs=", session, StringComparison.Ordinal); + Assert.Contains("confirmedResponseWaitMs=", session, StringComparison.Ordinal); + Assert.Contains("mmsDecodeMs=", session, StringComparison.Ordinal); + Assert.Contains("RegisterConfirmedOperation(expectedInvokeId)", transport, + StringComparison.Ordinal); + Assert.Contains("new TcpClient { NoDelay = true }", tcp, StringComparison.Ordinal); + } + + private static string ReadRepoFile(string path) + { + var folder = new DirectoryInfo(AppContext.BaseDirectory); + while (folder is not null) + { + var file = Path.Combine(folder.FullName, path.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(file)) + return File.ReadAllText(file); + folder = folder.Parent; + } + throw new FileNotFoundException(path); + } +}