From ca41ca25fbbf64f7c426885893af01ed87938acc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:13:21 +0700 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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 ba1c68527ee868f649de7deb156851efa2381df0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:43:19 +0700 Subject: [PATCH 10/12] perf(reporting): prime scalar RCB types from one structure TypeSpecification (#144) --- .../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 014cec2d3ff458b63e5e41b923c423b388ad1bd7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:43:30 +0700 Subject: [PATCH 11/12] perf(reporting): prewarm normal-path RCB scalar types before arming (#144) --- .../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 160c19bc15125cbd213751839205a451c3371d21 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:44:45 +0700 Subject: [PATCH 12/12] feat(reporting): expose asynchronous armed-to-traffic proof state (#144) --- .../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)}.";