diff --git a/docs/G2_6_SHADOW_VERIFICATION.md b/docs/G2_6_SHADOW_VERIFICATION.md
new file mode 100644
index 0000000..cf613ea
--- /dev/null
+++ b/docs/G2_6_SHADOW_VERIFICATION.md
@@ -0,0 +1,94 @@
+# G2.6 — Dynamic Report Shadow Verification
+
+## Purpose
+
+G2.6 shadow verification is the final evidence layer between an `InformationReportProven` device and any later `ProductionEligible` decision.
+
+It does **not** enable production dynamic reporting. It does **not** mutate an RCB, DataSet, or qualification profile. The engine only evaluates observations collected by a commissioning consumer.
+
+The shadow contract is:
+
+`dynamic InformationReport candidate -> independent MMS reference read -> exact identity/value/quality/timestamp/order/reconnect comparison -> typed acceptance evidence`
+
+The report path may be the intended production acquisition path, but during shadow verification independent MMS reads remain the authority used to detect missing, stale, duplicated, mis-mapped, or divergent report traffic.
+
+## Exact identity boundary
+
+Every observation is bound to the already-qualified DataSet member sequence by both:
+
+- exact DataSet index; and
+- normalized exact MMS member reference.
+
+No fuzzy match, vendor alias, neighboring DataObject guess, or alternate RCB substitution is permitted.
+
+Multiple included members from one InformationReport may share the same report sequence number. This is valid. A decreasing sequence number is an ordering failure, while an exact duplicate `(sequence number, DataSet index)` is rejected separately.
+
+## Evidence collected by the consumer
+
+`MmsDynamicReportShadowVerificationEvidence` contains:
+
+- immutable evidence ID and capture timestamp;
+- exact qualified member sequence;
+- report observations: index, member, value, quality, optional device timestamp, local receive time, optional sequence number;
+- independent polling observations: index, member, value, quality, optional device timestamp, read time;
+- reconnect attempts and successful reconnects;
+- report resubscription evidence after reconnect;
+- polling-reference recovery evidence after reconnect;
+- total dynamic activation attempts.
+
+The engine performs no network I/O while evaluating this object.
+
+## Required shadow gates
+
+`MmsDynamicReportShadowVerificationPolicy.Evaluate(...)` fails closed unless the configured gates close:
+
+1. **Exact member identity** — every report/poll sample maps to the same qualified DataSet index/member identity.
+2. **Value parity** — each accepted report observation has a bounded later independent MMS read with the same process value.
+3. **Quality parity** — when quality evidence is present, report and polling quality must agree. Commissioning may require quality evidence explicitly.
+4. **Timestamp parity** — when device timestamps are present, both sides must be present and within the configured tolerance. Commissioning may require timestamp evidence explicitly.
+5. **Report order** — receive time may not regress and report sequence number may not decrease.
+6. **No duplicate report edges** — the same sequence number and DataSet index may not appear more than once.
+7. **No missing report edges** — every value transition independently observed by polling must have a matching report observation inside the bounded correlation window.
+8. **Polling authority guard** — polling must remain available as an independent reference for every accepted report observation during the shadow.
+9. **Reconnect regression** — when required, every deliberate reconnect must recover both report subscription and the independent polling reference.
+10. **No repeated mutation loop** — dynamic activation attempts are bounded per association; reconnect recovery must not cause uncontrolled RCB/DataSet rewrite loops.
+
+Any failed gate produces a typed failure result and cannot be converted into production acceptance evidence.
+
+## Production acceptance bridge
+
+A successful shadow result can be converted through:
+
+`MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance(...)`
+
+This creates the existing `MmsDynamicReportProductionAcceptance` contract. It deliberately still requires the caller to provide two independent regression decisions:
+
+- Smart Control regression; and
+- static reporting regression.
+
+The shadow result supplies evidence for:
+
+- dynamic InformationReport regression;
+- polling-authority guard;
+- reconnect regression;
+- quality/timestamp regression; and
+- no repeated mutation loop.
+
+Creating this acceptance record still does **not** change profile state. The caller must separately invoke `MmsDynamicReportQualificationProfilePolicy.MarkProductionEligible(...)`, and that policy continues to require an identity-compatible `InformationReportProven` profile plus every production gate passing.
+
+## Intended ARSAS commissioning flow
+
+The consumer integration should remain explicit and fail closed:
+
+1. load the exact identity-compatible `InformationReportProven` profile;
+2. use only the exact proven RCB/member sequence;
+3. arm the dynamic report path with the established safety/cleanup contract;
+4. run an isolated read-only MMS reference association against the same exact members;
+5. collect report and polling observations during controlled normal process transitions;
+6. perform one deliberate reconnect cycle and prove both paths recover;
+7. stop and prove RCB/DataSet cleanup where the commissioning transaction requires it;
+8. evaluate the typed shadow evidence;
+9. retain `InformationReportProven` on any failure;
+10. consider `ProductionEligible` only after shadow PASS plus independent control/static-reporting acceptance.
+
+Production automatic dynamic reporting must remain OFF until the persisted profile is explicitly and validly advanced to `ProductionEligible`.
diff --git a/src/AR.Iec61850/Mms/MmsDynamicReportShadowVerification.cs b/src/AR.Iec61850/Mms/MmsDynamicReportShadowVerification.cs
new file mode 100644
index 0000000..a458b62
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsDynamicReportShadowVerification.cs
@@ -0,0 +1,386 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// One report-authoritative observation captured during G2.6 shadow verification.
+/// The DataSet index/member identity must be the exact identity already proven by
+/// the qualification profile; this model intentionally contains no fuzzy mapping.
+///
+public sealed record MmsDynamicReportShadowReportObservation
+{
+ public int DataSetIndex { get; init; }
+ public string MemberReference { get; init; } = string.Empty;
+ public string Value { get; init; } = string.Empty;
+ public string Quality { get; init; } = string.Empty;
+ public DateTimeOffset? DeviceTimestampUtc { get; init; }
+ public DateTimeOffset ReceivedAtUtc { get; init; }
+ public ulong? SequenceNumber { get; init; }
+}
+
+///
+/// One independent MMS read used only as the reference side of the G2.6 shadow.
+///
+public sealed record MmsDynamicReportShadowPollObservation
+{
+ public int DataSetIndex { get; init; }
+ public string MemberReference { get; init; } = string.Empty;
+ public string Value { get; init; } = string.Empty;
+ public string Quality { get; init; } = string.Empty;
+ public DateTimeOffset? DeviceTimestampUtc { get; init; }
+ public DateTimeOffset ReadAtUtc { get; init; }
+}
+
+///
+/// Bounded physical shadow evidence. Reconnect and mutation counters are explicit
+/// because production acceptance must prove that report recovery does not create a
+/// repeated RCB/DataSet mutation loop.
+///
+public sealed record MmsDynamicReportShadowVerificationEvidence
+{
+ public string EvidenceId { get; init; } = string.Empty;
+ public DateTimeOffset ObservedAtUtc { get; init; }
+ public IReadOnlyList MemberReferences { get; init; } = Array.Empty();
+ public IReadOnlyList ReportObservations { get; init; } = Array.Empty();
+ public IReadOnlyList PollObservations { get; init; } = Array.Empty();
+ public int ReconnectAttempts { get; init; }
+ public int SuccessfulReconnects { get; init; }
+ public int ReportResubscriptionsAfterReconnect { get; init; }
+ public int PollReferenceRecoveriesAfterReconnect { get; init; }
+ public int DynamicActivationAttempts { get; init; }
+}
+
+public sealed record MmsDynamicReportShadowVerificationOptions
+{
+ public static MmsDynamicReportShadowVerificationOptions ProductionDefaults { get; } = new();
+
+ public int MinimumReportEdges { get; init; } = 1;
+ public TimeSpan MaximumReportToPollLag { get; init; } = TimeSpan.FromSeconds(3);
+ public TimeSpan MaximumPollTransitionToReportLag { get; init; } = TimeSpan.FromSeconds(3);
+ public TimeSpan MaximumDeviceTimestampDelta { get; init; } = TimeSpan.FromMilliseconds(250);
+ public bool RequireQualityEvidence { get; init; }
+ public bool RequireDeviceTimestampEvidence { get; init; }
+ public bool RequireReconnectCycle { get; init; } = true;
+ public int MaximumDynamicActivationAttemptsPerAssociation { get; init; } = 1;
+}
+
+public sealed record MmsDynamicReportShadowVerificationResult
+{
+ public bool IsSuccess { get; init; }
+ public bool ExactMemberIdentityPassed { get; init; }
+ public bool ValueParityPassed { get; init; }
+ public bool QualityParityPassed { get; init; }
+ public bool TimestampParityPassed { get; init; }
+ public bool ReportOrderPassed { get; init; }
+ public bool NoMissingReportEdgesPassed { get; init; }
+ public bool NoDuplicateReportEdgesPassed { get; init; }
+ public bool PollingAuthorityGuardPassed { get; init; }
+ public bool ReconnectRegressionPassed { get; init; }
+ public bool NoRepeatedMutationLoopPassed { get; init; }
+ public int ReportObservationCount { get; init; }
+ public int PollObservationCount { get; init; }
+ public int PollTransitionCount { get; init; }
+ public int MatchedReportToPollCount { get; init; }
+ public int MatchedPollTransitionToReportCount { get; init; }
+ public IReadOnlyList Failures { get; init; } = Array.Empty();
+ public string Summary { get; init; } = string.Empty;
+}
+
+///
+/// Pure G2.6 acceptance evaluator. It does not perform network I/O and does not
+/// mutate an RCB, DataSet, or qualification profile. Consumers collect physical
+/// observations and pass them here for deterministic fail-closed evaluation.
+///
+public static class MmsDynamicReportShadowVerificationPolicy
+{
+ public static MmsDynamicReportShadowVerificationResult Evaluate(
+ MmsDynamicReportShadowVerificationEvidence evidence,
+ MmsDynamicReportShadowVerificationOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(evidence);
+ options ??= MmsDynamicReportShadowVerificationOptions.ProductionDefaults;
+ ValidateOptions(options);
+ ArgumentException.ThrowIfNullOrWhiteSpace(evidence.EvidenceId);
+
+ var failures = new List();
+ var members = evidence.MemberReferences
+ .Select(NormalizeReference)
+ .ToArray();
+
+ var memberSetValid = members.Length > 0 &&
+ members.All(reference => reference.Length > 0) &&
+ members.Distinct(StringComparer.OrdinalIgnoreCase).Count() == members.Length;
+ if (!memberSetValid)
+ failures.Add("Shadow verification requires one non-empty, duplicate-free exact member sequence.");
+
+ var reportIdentityPassed = memberSetValid && evidence.ReportObservations.All(observation =>
+ IsExactMember(observation.DataSetIndex, observation.MemberReference, members));
+ var pollIdentityPassed = memberSetValid && evidence.PollObservations.All(observation =>
+ IsExactMember(observation.DataSetIndex, observation.MemberReference, members));
+ var exactMemberIdentityPassed = reportIdentityPassed && pollIdentityPassed;
+ if (!exactMemberIdentityPassed)
+ failures.Add("At least one report/poll observation does not match the exact DataSet index/member identity.");
+
+ var originalReportOrder = evidence.ReportObservations.ToArray();
+ var reportOrderPassed = true;
+ for (var index = 1; index < originalReportOrder.Length; index++)
+ {
+ if (originalReportOrder[index].ReceivedAtUtc < originalReportOrder[index - 1].ReceivedAtUtc)
+ {
+ reportOrderPassed = false;
+ break;
+ }
+
+ var previousSequence = originalReportOrder[index - 1].SequenceNumber;
+ var currentSequence = originalReportOrder[index].SequenceNumber;
+ // Multiple included DataSet members from one InformationReport legitimately
+ // share the same sequence number. Only sequence regression is invalid here;
+ // same-sequence/same-index duplication is rejected separately below.
+ if (previousSequence.HasValue && currentSequence.HasValue && currentSequence.Value < previousSequence.Value)
+ {
+ reportOrderPassed = false;
+ break;
+ }
+ }
+ if (!reportOrderPassed)
+ failures.Add("Report receive/sequence order regressed inside the bounded shadow window.");
+
+ var duplicateKeys = evidence.ReportObservations
+ .Where(observation => observation.SequenceNumber.HasValue)
+ .GroupBy(observation => $"{observation.SequenceNumber!.Value}:{observation.DataSetIndex}", StringComparer.OrdinalIgnoreCase)
+ .Where(group => group.Count() > 1)
+ .Select(group => group.Key)
+ .ToArray();
+ var noDuplicateReportEdgesPassed = duplicateKeys.Length == 0;
+ if (!noDuplicateReportEdgesPassed)
+ failures.Add("Duplicate report edge(s) were observed for the same sequence number and DataSet index: " + string.Join(", ", duplicateKeys));
+
+ var reports = evidence.ReportObservations
+ .OrderBy(observation => observation.ReceivedAtUtc)
+ .ToArray();
+ var polls = evidence.PollObservations
+ .OrderBy(observation => observation.ReadAtUtc)
+ .ToArray();
+
+ var matchedReportToPoll = 0;
+ var valueParityPassed = true;
+ var qualityParityPassed = true;
+ var timestampParityPassed = true;
+ var sawQualityPair = false;
+ var sawTimestampPair = false;
+
+ foreach (var report in reports)
+ {
+ var poll = polls.FirstOrDefault(candidate =>
+ candidate.DataSetIndex == report.DataSetIndex &&
+ candidate.ReadAtUtc >= report.ReceivedAtUtc &&
+ candidate.ReadAtUtc - report.ReceivedAtUtc <= options.MaximumReportToPollLag);
+ if (poll is null)
+ continue;
+
+ matchedReportToPoll++;
+ if (!SameValue(report.Value, poll.Value))
+ valueParityPassed = false;
+
+ var reportQuality = NormalizeText(report.Quality);
+ var pollQuality = NormalizeText(poll.Quality);
+ if (reportQuality.Length > 0 || pollQuality.Length > 0)
+ {
+ sawQualityPair = true;
+ if (!reportQuality.Equals(pollQuality, StringComparison.OrdinalIgnoreCase))
+ qualityParityPassed = false;
+ }
+
+ if (report.DeviceTimestampUtc.HasValue || poll.DeviceTimestampUtc.HasValue)
+ {
+ if (!report.DeviceTimestampUtc.HasValue || !poll.DeviceTimestampUtc.HasValue)
+ {
+ timestampParityPassed = false;
+ }
+ else
+ {
+ sawTimestampPair = true;
+ var delta = (report.DeviceTimestampUtc.Value - poll.DeviceTimestampUtc.Value).Duration();
+ if (delta > options.MaximumDeviceTimestampDelta)
+ timestampParityPassed = false;
+ }
+ }
+ }
+
+ if (options.RequireQualityEvidence && !sawQualityPair)
+ qualityParityPassed = false;
+ if (options.RequireDeviceTimestampEvidence && !sawTimestampPair)
+ timestampParityPassed = false;
+
+ if (!valueParityPassed)
+ failures.Add("At least one report value disagrees with the independent MMS reference read.");
+ if (!qualityParityPassed)
+ failures.Add(options.RequireQualityEvidence && !sawQualityPair
+ ? "Required quality evidence was not captured on both report and polling sides."
+ : "At least one report quality disagrees with the independent MMS reference read.");
+ if (!timestampParityPassed)
+ failures.Add(options.RequireDeviceTimestampEvidence && !sawTimestampPair
+ ? "Required device timestamp evidence was not captured on both report and polling sides."
+ : "At least one report/poll device timestamp pair exceeds the configured tolerance or is one-sided.");
+
+ var pollTransitions = BuildPollTransitions(polls);
+ var matchedPollTransitions = 0;
+ foreach (var transition in pollTransitions)
+ {
+ var matchingReport = reports.FirstOrDefault(report =>
+ report.DataSetIndex == transition.Current.DataSetIndex &&
+ SameValue(report.Value, transition.Current.Value) &&
+ report.ReceivedAtUtc >= transition.Previous.ReadAtUtc &&
+ report.ReceivedAtUtc <= transition.Current.ReadAtUtc + options.MaximumPollTransitionToReportLag);
+ if (matchingReport is not null)
+ matchedPollTransitions++;
+ }
+
+ var noMissingReportEdgesPassed = pollTransitions.Count == matchedPollTransitions;
+ if (!noMissingReportEdgesPassed)
+ failures.Add($"Independent MMS polling observed {pollTransitions.Count} value transition(s), but only {matchedPollTransitions} had a matching report edge in the bounded window.");
+
+ var enoughReportEdges = reports.Length >= options.MinimumReportEdges;
+ if (!enoughReportEdges)
+ failures.Add($"Shadow verification captured {reports.Length} report edge(s); at least {options.MinimumReportEdges} are required.");
+
+ var pollingAuthorityGuardPassed = polls.Length > 0 &&
+ matchedReportToPoll == reports.Length &&
+ noMissingReportEdgesPassed;
+ if (!pollingAuthorityGuardPassed)
+ failures.Add("Independent MMS polling did not remain an authoritative reference for every report edge.");
+
+ var reconnectRegressionPassed = options.RequireReconnectCycle
+ ? evidence.ReconnectAttempts > 0 &&
+ evidence.SuccessfulReconnects == evidence.ReconnectAttempts &&
+ evidence.ReportResubscriptionsAfterReconnect >= evidence.SuccessfulReconnects &&
+ evidence.PollReferenceRecoveriesAfterReconnect >= evidence.SuccessfulReconnects
+ : evidence.SuccessfulReconnects <= evidence.ReconnectAttempts;
+ if (!reconnectRegressionPassed)
+ failures.Add("Reconnect regression did not prove both report resubscription and independent polling-reference recovery.");
+
+ var associationCount = 1 + Math.Max(0, evidence.SuccessfulReconnects);
+ var allowedDynamicAttempts = checked(associationCount * options.MaximumDynamicActivationAttemptsPerAssociation);
+ var noRepeatedMutationLoopPassed = evidence.DynamicActivationAttempts >= 0 &&
+ evidence.DynamicActivationAttempts <= allowedDynamicAttempts;
+ if (!noRepeatedMutationLoopPassed)
+ failures.Add($"Dynamic activation attempts ({evidence.DynamicActivationAttempts}) exceed the bounded allowance ({allowedDynamicAttempts}); repeated mutation loop cannot be excluded.");
+
+ var success = exactMemberIdentityPassed &&
+ enoughReportEdges &&
+ valueParityPassed &&
+ qualityParityPassed &&
+ timestampParityPassed &&
+ reportOrderPassed &&
+ noMissingReportEdgesPassed &&
+ noDuplicateReportEdgesPassed &&
+ pollingAuthorityGuardPassed &&
+ reconnectRegressionPassed &&
+ noRepeatedMutationLoopPassed;
+
+ return new MmsDynamicReportShadowVerificationResult
+ {
+ IsSuccess = success,
+ ExactMemberIdentityPassed = exactMemberIdentityPassed,
+ ValueParityPassed = valueParityPassed,
+ QualityParityPassed = qualityParityPassed,
+ TimestampParityPassed = timestampParityPassed,
+ ReportOrderPassed = reportOrderPassed,
+ NoMissingReportEdgesPassed = noMissingReportEdgesPassed,
+ NoDuplicateReportEdgesPassed = noDuplicateReportEdgesPassed,
+ PollingAuthorityGuardPassed = pollingAuthorityGuardPassed,
+ ReconnectRegressionPassed = reconnectRegressionPassed,
+ NoRepeatedMutationLoopPassed = noRepeatedMutationLoopPassed,
+ ReportObservationCount = reports.Length,
+ PollObservationCount = polls.Length,
+ PollTransitionCount = pollTransitions.Count,
+ MatchedReportToPollCount = matchedReportToPoll,
+ MatchedPollTransitionToReportCount = matchedPollTransitions,
+ Failures = failures.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
+ Summary = success
+ ? $"G2.6 shadow PASS: reports={reports.Length}, polls={polls.Length}, pollTransitions={pollTransitions.Count}, reconnects={evidence.SuccessfulReconnects}, dynamicAttempts={evidence.DynamicActivationAttempts}."
+ : $"G2.6 shadow FAIL: {string.Join(" | ", failures.Distinct(StringComparer.OrdinalIgnoreCase))}"
+ };
+ }
+
+ ///
+ /// Converts a successful typed shadow result into the existing production-acceptance
+ /// contract. Control/static-report regressions remain explicit independent inputs.
+ /// This helper does not modify a qualification profile.
+ ///
+ public static MmsDynamicReportProductionAcceptance BuildProductionAcceptance(
+ MmsDynamicReportShadowVerificationEvidence evidence,
+ MmsDynamicReportShadowVerificationResult shadow,
+ bool controlRegressionPassed,
+ bool staticReportingRegressionPassed)
+ {
+ ArgumentNullException.ThrowIfNull(evidence);
+ ArgumentNullException.ThrowIfNull(shadow);
+ ArgumentException.ThrowIfNullOrWhiteSpace(evidence.EvidenceId);
+ if (!shadow.IsSuccess)
+ throw new InvalidOperationException("A failed G2.6 shadow cannot be converted into production acceptance evidence.");
+
+ return new MmsDynamicReportProductionAcceptance
+ {
+ FieldEvidenceId = evidence.EvidenceId.Trim(),
+ ObservedAtUtc = evidence.ObservedAtUtc,
+ ControlRegressionPassed = controlRegressionPassed,
+ StaticReportingRegressionPassed = staticReportingRegressionPassed,
+ DynamicInformationReportRegressionPassed = shadow.ExactMemberIdentityPassed &&
+ shadow.ValueParityPassed &&
+ shadow.ReportOrderPassed &&
+ shadow.NoMissingReportEdgesPassed &&
+ shadow.NoDuplicateReportEdgesPassed,
+ PollingAuthorityGuardPassed = shadow.PollingAuthorityGuardPassed,
+ ReconnectRegressionPassed = shadow.ReconnectRegressionPassed,
+ QualityRegressionPassed = shadow.QualityParityPassed && shadow.TimestampParityPassed,
+ NoRepeatedMutationLoopPassed = shadow.NoRepeatedMutationLoopPassed
+ };
+ }
+
+ private static IReadOnlyList BuildPollTransitions(
+ IReadOnlyList polls)
+ {
+ var transitions = new List();
+ foreach (var group in polls.GroupBy(observation => observation.DataSetIndex))
+ {
+ var ordered = group.OrderBy(observation => observation.ReadAtUtc).ToArray();
+ for (var index = 1; index < ordered.Length; index++)
+ {
+ if (!SameValue(ordered[index - 1].Value, ordered[index].Value))
+ transitions.Add(new PollTransition(ordered[index - 1], ordered[index]));
+ }
+ }
+ return transitions;
+ }
+
+ private static bool IsExactMember(int index, string reference, IReadOnlyList members)
+ => index >= 0 &&
+ index < members.Count &&
+ NormalizeReference(reference).Equals(members[index], StringComparison.OrdinalIgnoreCase);
+
+ private static bool SameValue(string left, string right)
+ => NormalizeText(left).Equals(NormalizeText(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeReference(string? reference)
+ => NormalizeText(reference).Replace('$', '.');
+
+ private static string NormalizeText(string? text)
+ => string.IsNullOrWhiteSpace(text) ? string.Empty : text.Trim();
+
+ private static void ValidateOptions(MmsDynamicReportShadowVerificationOptions options)
+ {
+ if (options.MinimumReportEdges < 1)
+ throw new ArgumentOutOfRangeException(nameof(options.MinimumReportEdges));
+ if (options.MaximumReportToPollLag <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(options.MaximumReportToPollLag));
+ if (options.MaximumPollTransitionToReportLag < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(options.MaximumPollTransitionToReportLag));
+ if (options.MaximumDeviceTimestampDelta < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(options.MaximumDeviceTimestampDelta));
+ if (options.MaximumDynamicActivationAttemptsPerAssociation < 1)
+ throw new ArgumentOutOfRangeException(nameof(options.MaximumDynamicActivationAttemptsPerAssociation));
+ }
+
+ private sealed record PollTransition(
+ MmsDynamicReportShadowPollObservation Previous,
+ MmsDynamicReportShadowPollObservation Current);
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowVerificationTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowVerificationTests.cs
new file mode 100644
index 0000000..362dfba
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowVerificationTests.cs
@@ -0,0 +1,232 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsG26ShadowVerificationTests
+{
+ [Fact]
+ public void MatchingReportAndPollEdges_WithReconnect_PassProductionShadow()
+ {
+ var evidence = PassingEvidence();
+ var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(
+ evidence,
+ StrictOptions());
+
+ Assert.True(result.IsSuccess, result.Summary);
+ Assert.True(result.ExactMemberIdentityPassed);
+ Assert.True(result.ValueParityPassed);
+ Assert.True(result.QualityParityPassed);
+ Assert.True(result.TimestampParityPassed);
+ Assert.True(result.ReportOrderPassed);
+ Assert.True(result.NoMissingReportEdgesPassed);
+ Assert.True(result.NoDuplicateReportEdgesPassed);
+ Assert.True(result.PollingAuthorityGuardPassed);
+ Assert.True(result.ReconnectRegressionPassed);
+ Assert.True(result.NoRepeatedMutationLoopPassed);
+ Assert.Equal(2, result.PollTransitionCount);
+ Assert.Equal(2, result.MatchedPollTransitionToReportCount);
+ }
+
+ [Fact]
+ public void OneInformationReportFrame_CanCarryMultipleIndexesWithSameSequenceNumber()
+ {
+ var evidence = PassingEvidence() with
+ {
+ ReportObservations =
+ [
+ Report(0, "Open", 10, Time(110), Time(100)),
+ Report(1, "Open", 10, Time(110), Time(100))
+ ]
+ };
+
+ var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions());
+
+ Assert.True(result.ReportOrderPassed, result.Summary);
+ Assert.True(result.NoDuplicateReportEdgesPassed, result.Summary);
+ }
+
+ [Fact]
+ public void PollTransitionWithoutMatchingReport_FailsMissingEdgeAndPollingAuthority()
+ {
+ var evidence = PassingEvidence() with
+ {
+ ReportObservations = [Report(0, "Open", 10, Time(110), Time(100))]
+ };
+
+ var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions());
+
+ Assert.False(result.IsSuccess);
+ Assert.False(result.NoMissingReportEdgesPassed);
+ Assert.False(result.PollingAuthorityGuardPassed);
+ Assert.Contains(result.Failures, failure => failure.Contains("only 1", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void DuplicateSameSequenceAndIndex_FailsClosed()
+ {
+ var reports = PassingEvidence().ReportObservations.ToList();
+ reports.Add(Report(0, "Open", 10, Time(111), Time(100)));
+ var evidence = PassingEvidence() with { ReportObservations = reports };
+
+ var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions());
+
+ Assert.False(result.IsSuccess);
+ Assert.False(result.NoDuplicateReportEdgesPassed);
+ }
+
+ [Fact]
+ public void ValueQualityOrTimestampMismatch_FailsParity()
+ {
+ var polls = PassingEvidence().PollObservations.ToArray();
+ polls[2] = polls[2] with
+ {
+ Value = "Closed",
+ Quality = "questionable",
+ DeviceTimestampUtc = Time(500)
+ };
+ var evidence = PassingEvidence() with { PollObservations = polls };
+
+ var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions());
+
+ Assert.False(result.IsSuccess);
+ Assert.False(result.ValueParityPassed);
+ Assert.False(result.QualityParityPassed);
+ Assert.False(result.TimestampParityPassed);
+ }
+
+ [Fact]
+ public void ReconnectWithoutReportAndPollRecovery_FailsClosed()
+ {
+ var evidence = PassingEvidence() with
+ {
+ ReportResubscriptionsAfterReconnect = 0,
+ PollReferenceRecoveriesAfterReconnect = 0
+ };
+
+ var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions());
+
+ Assert.False(result.IsSuccess);
+ Assert.False(result.ReconnectRegressionPassed);
+ }
+
+ [Fact]
+ public void RepeatedDynamicActivationLoop_FailsClosed()
+ {
+ var evidence = PassingEvidence() with { DynamicActivationAttempts = 3 };
+
+ var result = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions());
+
+ Assert.False(result.IsSuccess);
+ Assert.False(result.NoRepeatedMutationLoopPassed);
+ Assert.Contains(result.Failures, failure => failure.Contains("repeated mutation", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void SuccessfulTypedShadow_BuildsExistingProductionAcceptanceContract()
+ {
+ var evidence = PassingEvidence();
+ var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions());
+
+ var acceptance = MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance(
+ evidence,
+ shadow,
+ controlRegressionPassed: true,
+ staticReportingRegressionPassed: true);
+
+ Assert.True(acceptance.AllPassed);
+ Assert.Equal(evidence.EvidenceId, acceptance.FieldEvidenceId);
+ Assert.True(acceptance.DynamicInformationReportRegressionPassed);
+ Assert.True(acceptance.PollingAuthorityGuardPassed);
+ Assert.True(acceptance.ReconnectRegressionPassed);
+ Assert.True(acceptance.QualityRegressionPassed);
+ Assert.True(acceptance.NoRepeatedMutationLoopPassed);
+ }
+
+ [Fact]
+ public void FailedShadow_CannotBuildProductionAcceptance()
+ {
+ var evidence = PassingEvidence() with { ReportObservations = [] };
+ var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate(evidence, StrictOptions());
+
+ Assert.False(shadow.IsSuccess);
+ Assert.Throws(() =>
+ MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance(
+ evidence,
+ shadow,
+ controlRegressionPassed: true,
+ staticReportingRegressionPassed: true));
+ }
+
+ private static MmsDynamicReportShadowVerificationEvidence PassingEvidence()
+ => new()
+ {
+ EvidenceId = "g2.6-shadow-synthetic",
+ ObservedAtUtc = Time(1000),
+ MemberReferences = [Member(0), Member(1)],
+ ReportObservations =
+ [
+ Report(0, "Open", 10, Time(110), Time(100)),
+ Report(1, "Open", 10, Time(110), Time(100))
+ ],
+ PollObservations =
+ [
+ Poll(0, "Closed", Time(90), Time(90)),
+ Poll(1, "Closed", Time(90), Time(90)),
+ Poll(0, "Open", Time(120), Time(100)),
+ Poll(1, "Open", Time(120), Time(100))
+ ],
+ ReconnectAttempts = 1,
+ SuccessfulReconnects = 1,
+ ReportResubscriptionsAfterReconnect = 1,
+ PollReferenceRecoveriesAfterReconnect = 1,
+ DynamicActivationAttempts = 2
+ };
+
+ private static MmsDynamicReportShadowVerificationOptions StrictOptions()
+ => new()
+ {
+ MinimumReportEdges = 2,
+ MaximumReportToPollLag = TimeSpan.FromSeconds(5),
+ MaximumPollTransitionToReportLag = TimeSpan.FromSeconds(5),
+ MaximumDeviceTimestampDelta = TimeSpan.FromMilliseconds(250),
+ RequireQualityEvidence = true,
+ RequireDeviceTimestampEvidence = true,
+ RequireReconnectCycle = true,
+ MaximumDynamicActivationAttemptsPerAssociation = 1
+ };
+
+ private static MmsDynamicReportShadowReportObservation Report(
+ int index,
+ string value,
+ ulong sequence,
+ DateTimeOffset receivedAt,
+ DateTimeOffset deviceTimestamp)
+ => new()
+ {
+ DataSetIndex = index,
+ MemberReference = Member(index),
+ Value = value,
+ Quality = "good",
+ DeviceTimestampUtc = deviceTimestamp,
+ ReceivedAtUtc = receivedAt,
+ SequenceNumber = sequence
+ };
+
+ private static MmsDynamicReportShadowPollObservation Poll(
+ int index,
+ string value,
+ DateTimeOffset readAt,
+ DateTimeOffset deviceTimestamp)
+ => new()
+ {
+ DataSetIndex = index,
+ MemberReference = Member(index),
+ Value = value,
+ Quality = "good",
+ DeviceTimestampUtc = deviceTimestamp,
+ ReadAtUtc = readAt
+ };
+
+ private static string Member(int index) => $"LD0/LN0$ST$Pos{index}$stVal";
+ private static DateTimeOffset Time(int milliseconds) => DateTimeOffset.UnixEpoch.AddMilliseconds(milliseconds);
+}