diff --git a/src/AR.Iec61850/Mms/MmsDynamicReportShadowProductionAcceptancePolicy.cs b/src/AR.Iec61850/Mms/MmsDynamicReportShadowProductionAcceptancePolicy.cs
new file mode 100644
index 0000000..30e1cea
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsDynamicReportShadowProductionAcceptancePolicy.cs
@@ -0,0 +1,83 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// Production-facing bridge from a successful G2.6 shadow result to the persisted
+/// production-acceptance contract. Unlike the generic shadow bridge, this policy
+/// refuses to mark QualityRegressionPassed unless actual paired quality AND device
+/// timestamp evidence were observed on report and independent-poll sides.
+///
+public static class MmsDynamicReportShadowProductionAcceptancePolicy
+{
+ public static MmsDynamicReportProductionAcceptance BuildStrict(
+ 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 strict production acceptance evidence.");
+
+ var baseAcceptance = MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance(
+ evidence,
+ shadow,
+ controlRegressionPassed,
+ staticReportingRegressionPassed);
+
+ var qualityEvidenceObserved = HasPairedQualityEvidence(evidence);
+ var timestampEvidenceObserved = HasPairedTimestampEvidence(evidence);
+
+ return new MmsDynamicReportProductionAcceptance
+ {
+ FieldEvidenceId = baseAcceptance.FieldEvidenceId,
+ ObservedAtUtc = baseAcceptance.ObservedAtUtc,
+ ControlRegressionPassed = baseAcceptance.ControlRegressionPassed,
+ StaticReportingRegressionPassed = baseAcceptance.StaticReportingRegressionPassed,
+ DynamicInformationReportRegressionPassed = baseAcceptance.DynamicInformationReportRegressionPassed,
+ PollingAuthorityGuardPassed = baseAcceptance.PollingAuthorityGuardPassed,
+ ReconnectRegressionPassed = baseAcceptance.ReconnectRegressionPassed,
+ QualityRegressionPassed = baseAcceptance.QualityRegressionPassed &&
+ qualityEvidenceObserved &&
+ timestampEvidenceObserved,
+ NoRepeatedMutationLoopPassed = baseAcceptance.NoRepeatedMutationLoopPassed
+ };
+ }
+
+ public static bool HasPairedQualityEvidence(MmsDynamicReportShadowVerificationEvidence evidence)
+ {
+ ArgumentNullException.ThrowIfNull(evidence);
+ return evidence.ReportObservations.Any(report =>
+ {
+ var reportQuality = Normalize(report.Quality);
+ if (reportQuality.Length == 0)
+ return false;
+
+ return evidence.PollObservations.Any(poll =>
+ poll.DataSetIndex == report.DataSetIndex &&
+ SameReference(poll.MemberReference, report.MemberReference) &&
+ Normalize(poll.Quality).Length > 0);
+ });
+ }
+
+ public static bool HasPairedTimestampEvidence(MmsDynamicReportShadowVerificationEvidence evidence)
+ {
+ ArgumentNullException.ThrowIfNull(evidence);
+ return evidence.ReportObservations.Any(report =>
+ report.DeviceTimestampUtc.HasValue &&
+ evidence.PollObservations.Any(poll =>
+ poll.DataSetIndex == report.DataSetIndex &&
+ SameReference(poll.MemberReference, report.MemberReference) &&
+ poll.DeviceTimestampUtc.HasValue));
+ }
+
+ private static bool SameReference(string? left, string? right)
+ => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeReference(string? reference)
+ => Normalize(reference).Replace('$', '.');
+
+ private static string Normalize(string? value)
+ => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowProductionAcceptancePolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowProductionAcceptancePolicyTests.cs
new file mode 100644
index 0000000..02ece42
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsG26ShadowProductionAcceptancePolicyTests.cs
@@ -0,0 +1,164 @@
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsG26ShadowProductionAcceptancePolicyTests
+{
+ [Fact]
+ public void StrictProductionAcceptance_AllPassed_WhenPairedQualityAndTimestampEvidenceExist()
+ {
+ var evidence = Evidence(includeQuality: true, includeTimestamp: true);
+ var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate(
+ evidence,
+ Options(requireQuality: true, requireTimestamp: true));
+
+ Assert.True(shadow.IsSuccess, shadow.Summary);
+
+ var acceptance = MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict(
+ evidence,
+ shadow,
+ controlRegressionPassed: true,
+ staticReportingRegressionPassed: true);
+
+ Assert.True(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence(evidence));
+ Assert.True(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence(evidence));
+ Assert.True(acceptance.QualityRegressionPassed);
+ Assert.True(acceptance.AllPassed);
+ }
+
+ [Fact]
+ public void OptionalQualityShadow_CannotAccidentallyGrantProductionQualityGateWithoutEvidence()
+ {
+ var evidence = Evidence(includeQuality: false, includeTimestamp: false);
+ var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate(
+ evidence,
+ Options(requireQuality: false, requireTimestamp: false));
+
+ Assert.True(shadow.IsSuccess, shadow.Summary);
+
+ var generic = MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance(
+ evidence,
+ shadow,
+ controlRegressionPassed: true,
+ staticReportingRegressionPassed: true);
+ var strict = MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict(
+ evidence,
+ shadow,
+ controlRegressionPassed: true,
+ staticReportingRegressionPassed: true);
+
+ Assert.True(generic.QualityRegressionPassed);
+ Assert.False(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence(evidence));
+ Assert.False(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence(evidence));
+ Assert.False(strict.QualityRegressionPassed);
+ Assert.False(strict.AllPassed);
+ }
+
+ [Fact]
+ public void QualityWithoutTimestamp_RemainsProductionIneligible()
+ {
+ var evidence = Evidence(includeQuality: true, includeTimestamp: false);
+ var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate(
+ evidence,
+ Options(requireQuality: true, requireTimestamp: false));
+
+ Assert.True(shadow.IsSuccess, shadow.Summary);
+ var strict = MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict(
+ evidence,
+ shadow,
+ controlRegressionPassed: true,
+ staticReportingRegressionPassed: true);
+
+ Assert.True(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence(evidence));
+ Assert.False(MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence(evidence));
+ Assert.False(strict.QualityRegressionPassed);
+ Assert.False(strict.AllPassed);
+ }
+
+ [Fact]
+ public void FailedShadow_CannotBuildStrictProductionAcceptance()
+ {
+ var evidence = Evidence(includeQuality: true, includeTimestamp: true) with
+ {
+ ReportObservations = []
+ };
+ var shadow = MmsDynamicReportShadowVerificationPolicy.Evaluate(
+ evidence,
+ Options(requireQuality: true, requireTimestamp: true));
+
+ Assert.False(shadow.IsSuccess);
+ Assert.Throws(() =>
+ MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict(
+ evidence,
+ shadow,
+ controlRegressionPassed: true,
+ staticReportingRegressionPassed: true));
+ }
+
+ private static MmsDynamicReportShadowVerificationEvidence Evidence(bool includeQuality, bool includeTimestamp)
+ {
+ var quality = includeQuality ? "good" : string.Empty;
+ DateTimeOffset? deviceTimestamp = includeTimestamp ? Time(100) : null;
+ return new MmsDynamicReportShadowVerificationEvidence
+ {
+ EvidenceId = "g2.6-strict-production-test",
+ ObservedAtUtc = Time(1000),
+ MemberReferences = [Member],
+ ReportObservations =
+ [
+ new MmsDynamicReportShadowReportObservation
+ {
+ DataSetIndex = 0,
+ MemberReference = Member,
+ Value = "Open",
+ Quality = quality,
+ DeviceTimestampUtc = deviceTimestamp,
+ ReceivedAtUtc = Time(110),
+ SequenceNumber = 10
+ }
+ ],
+ PollObservations =
+ [
+ new MmsDynamicReportShadowPollObservation
+ {
+ DataSetIndex = 0,
+ MemberReference = Member,
+ Value = "Closed",
+ Quality = quality,
+ DeviceTimestampUtc = includeTimestamp ? Time(90) : null,
+ ReadAtUtc = Time(90)
+ },
+ new MmsDynamicReportShadowPollObservation
+ {
+ DataSetIndex = 0,
+ MemberReference = Member,
+ Value = "Open",
+ Quality = quality,
+ DeviceTimestampUtc = deviceTimestamp,
+ ReadAtUtc = Time(120)
+ }
+ ],
+ ReconnectAttempts = 1,
+ SuccessfulReconnects = 1,
+ ReportResubscriptionsAfterReconnect = 1,
+ PollReferenceRecoveriesAfterReconnect = 1,
+ DynamicActivationAttempts = 2
+ };
+ }
+
+ private static MmsDynamicReportShadowVerificationOptions Options(bool requireQuality, bool requireTimestamp)
+ => new()
+ {
+ MinimumReportEdges = 1,
+ MaximumReportToPollLag = TimeSpan.FromSeconds(5),
+ MaximumPollTransitionToReportLag = TimeSpan.FromSeconds(5),
+ MaximumDeviceTimestampDelta = TimeSpan.FromMilliseconds(250),
+ RequireQualityEvidence = requireQuality,
+ RequireDeviceTimestampEvidence = requireTimestamp,
+ RequireReconnectCycle = true,
+ MaximumDynamicActivationAttemptsPerAssociation = 1
+ };
+
+ private const string Member = "LD0/LN0$ST$Pos$stVal";
+ private static DateTimeOffset Time(int milliseconds) => DateTimeOffset.UnixEpoch.AddMilliseconds(milliseconds);
+}