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}");
+ }
+ }
+}
diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs
index 5a5ec185..af26a9cb 100644
--- a/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs
+++ b/src/AR.Iec61850/Mms/MmsClientSession.SclStaticReport.cs
@@ -101,10 +101,44 @@ public async Task StartConfiguredStaticRe
try
{
- snapshots.Add(await CaptureReportControlSnapshotAsync(
+ var beforeSnapshot = await CaptureConfiguredStaticActivationSnapshotAsync(
rcb,
- "scl-static-before-enable",
- cancellationToken).ConfigureAwait(false));
+ "configured-static-before-enable",
+ includeReservationEvidence: true,
+ 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}"
+ };
+ }
+
+ 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,
@@ -185,14 +219,51 @@ public async Task StartConfiguredStaticRe
monitor.EnabledByThisClient = true;
- snapshots.Add(await CaptureReportControlSnapshotAsync(
+ var afterEnable = await CaptureConfiguredStaticActivationSnapshotAsync(
rcb,
- "scl-static-after-enable-1",
- cancellationToken).ConfigureAwait(false));
- snapshots.Add(await CaptureReportControlSnapshotAsync(
- rcb,
- "scl-static-after-enable-2",
- cancellationToken).ConfigureAwait(false));
+ "configured-static-after-enable",
+ includeReservationEvidence: false,
+ cancellationToken).ConfigureAwait(false);
+ snapshots.Add(afterEnable);
+
+ var enableProof = MmsConfiguredStaticActivationVerifier.VerifyAfterEnable(
+ afterEnable,
+ 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)
{
diff --git a/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs
new file mode 100644
index 00000000..232a4f56
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsConfiguredStaticActivationVerifier.cs
@@ -0,0 +1,172 @@
+namespace AR.Iec61850.Mms;
+
+public enum MmsConfiguredStaticActivationProofKind
+{
+ Proven,
+ SnapshotReadFailed,
+ ReportEnableStateMismatch,
+ DataSetBindingMismatch,
+ BusyRuntimeEvidence,
+ 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 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
+{
+ public static MmsConfiguredStaticActivationProof VerifyBeforeWrite(
+ MmsReportRcbSnapshot snapshot,
+ string expectedDataSetReference)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ if (!snapshot.IsSuccess)
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.SnapshotReadFailed,
+ snapshot,
+ expectedDataSetReference,
+ "Pre-activation JIT RCB read failed; no activation write is permitted.");
+
+ if (HasPositiveDataSetMismatch(snapshot, 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.");
+
+ 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(
+ MmsReportRcbSnapshot snapshot,
+ string expectedDataSetReference)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ if (!snapshot.IsSuccess)
+ return Fail(
+ MmsConfiguredStaticActivationProofKind.SnapshotReadFailed,
+ snapshot,
+ expectedDataSetReference,
+ "Post-enable JIT readback failed; write acceptance alone is not activation proof.");
+
+ 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(
+ snapshot,
+ expectedDataSetReference,
+ "Post-enable JIT readback proves RptEna=true with no conflicting live DataSet binding.");
+ }
+
+ private static bool HasPositiveBusyEvidence(MmsReportRcbSnapshot snapshot)
+ {
+ 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);
+ }
+
+ 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()
+ };
+}
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)}.";
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
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,
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs
new file mode 100644
index 00000000..e69606b5
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsConfiguredStaticActivationVerifierTests.cs
@@ -0,0 +1,154 @@
+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_Unread_DataSet()
+ {
+ 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(unread, "LD0/LLN0.DS01").IsProven);
+ }
+
+ [Fact]
+ public void BeforeWrite_Blocks_Positive_Empty_Live_DataSet()
+ {
+ var empty = Snapshot(
+ success: true,
+ enabled: "false",
+ dataSet: string.Empty,
+ stage: "before",
+ dataSetProbeState: MmsRcbDataSetProbeState.ReadSucceeded);
+
+ var proof = MmsConfiguredStaticActivationVerifier.VerifyBeforeWrite(empty, "LD0/LLN0.DS01");
+
+ Assert.Equal(MmsConfiguredStaticActivationProofKind.DataSetBindingMismatch, proof.Kind);
+ }
+
+ [Fact]
+ public void BeforeWrite_Blocks_Positive_Busy_Evidence()
+ {
+ 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,
+ reservationState: "true");
+
+ 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 BeforeWrite_Fails_Closed_When_Jit_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_One_Exact_Readback_Is_Sufficient()
+ {
+ var snapshot = Snapshot(
+ success: true,
+ enabled: "true",
+ dataSet: "LD0/LLN0.DS01",
+ stage: "after-enable");
+
+ 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,
+ bool buffered = true,
+ MmsRcbDataSetProbeState dataSetProbeState = MmsRcbDataSetProbeState.NotAttempted,
+ string reservationState = "",
+ string reservationTimeSeconds = "",
+ string owner = "")
+ => new()
+ {
+ Stage = stage,
+ IsSuccess = success,
+ Reference = buffered ? "LD0/LLN0.BR.B01" : "LD0/LLN0.RP.U01",
+ Buffered = buffered,
+ EnabledState = enabled,
+ DataSetReference = dataSet,
+ DataSetProbeState = dataSetProbeState,
+ ReservationState = reservationState,
+ ReservationTimeSeconds = reservationTimeSeconds,
+ Owner = owner,
+ Message = success ? "snapshot ok" : "snapshot failed"
+ };
+}