diff --git a/src/AR.Iec61850/Mms/MmsG26GuardedLegacyCompatibility.cs b/src/AR.Iec61850/Mms/MmsG26GuardedLegacyCompatibility.cs
new file mode 100644
index 0000000..60c10ad
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsG26GuardedLegacyCompatibility.cs
@@ -0,0 +1,248 @@
+namespace AR.Iec61850.Mms;
+
+///
+/// Application-supplied compatibility evidence for a legacy InformationReportProven profile
+/// whose persisted InformationReport proof predates an explicit spontaneous data-change proof.
+///
+/// This evidence is intentionally separate from the persisted qualification profile. It may
+/// only unlock the existing guarded-runtime planner after the engine verifies exact current
+/// identity, exact proven RCB, exact ordered member sequence, a real dchg InformationReport,
+/// NO-GI operation, association health, and complete cleanup. It never authorizes
+/// ProductionEligible and it is never persisted by this policy.
+///
+public sealed record MmsDynamicReportLegacyDataChangeCompatibilityEvidence
+{
+ public string EvidenceId { get; init; } = string.Empty;
+ public string StableIdentityKey { get; init; } = string.Empty;
+ public string ModelFingerprint { get; init; } = string.Empty;
+ public string ProfileRevision { get; init; } = string.Empty;
+ public string RcbReference { get; init; } = string.Empty;
+ public IReadOnlyList MemberReferences { get; init; } = Array.Empty();
+ public bool ActualInformationReportReceived { get; init; }
+ public bool DataChangeReasonVerified { get; init; }
+ public bool GeneralInterrogationDisabled { get; init; }
+ public bool ExactMemberMappingVerified { get; init; }
+ public bool AssociationHealthyAfterReport { get; init; }
+ public bool CleanupSucceeded { get; init; }
+
+ public bool IsSuccess =>
+ !string.IsNullOrWhiteSpace(EvidenceId) &&
+ !string.IsNullOrWhiteSpace(StableIdentityKey) &&
+ !string.IsNullOrWhiteSpace(ModelFingerprint) &&
+ !string.IsNullOrWhiteSpace(RcbReference) &&
+ MemberReferences.Count > 0 &&
+ ActualInformationReportReceived &&
+ DataChangeReasonVerified &&
+ GeneralInterrogationDisabled &&
+ ExactMemberMappingVerified &&
+ AssociationHealthyAfterReport &&
+ CleanupSucceeded;
+}
+
+///
+/// P1.5 compatibility adapter for legacy InformationReportProven profiles.
+///
+/// The persisted profile is treated as untrusted input and is never mutated. If the stored
+/// proof is already DataChange, the original context is returned unchanged. Otherwise a
+/// compatibility view is created in memory only after the supplied physical evidence matches
+/// the exact current identity, RCB, and ordered member sequence already present in the valid
+/// persisted chain. The normal guarded planner then re-runs all of its fresh capability,
+/// availability, exact-envelope and one-dynamic-group gates.
+///
+public static class MmsGuardedDynamicReportLegacyCompatibilityPolicy
+{
+ public static bool TryBuildCompatibleContext(
+ MmsDynamicReportGuardedRuntimePlanningContext sourceContext,
+ MmsDynamicReportLegacyDataChangeCompatibilityEvidence? evidence,
+ out MmsDynamicReportGuardedRuntimePlanningContext compatibleContext,
+ out string reason)
+ {
+ ArgumentNullException.ThrowIfNull(sourceContext);
+
+ compatibleContext = sourceContext;
+ var profile = sourceContext.Profile;
+ var currentIdentity = sourceContext.CurrentIdentity;
+ var report = profile.InformationReportProof;
+
+ if (report?.Kind == MmsDynamicInformationReportKind.DataChange)
+ {
+ reason = "Stored InformationReport proof is already DataChange; no legacy compatibility adaptation is required.";
+ return true;
+ }
+
+ if (profile.SchemaVersion != MmsDynamicReportQualificationProfile.CurrentSchemaVersion)
+ {
+ reason = $"Unsupported dynamic qualification profile schema {profile.SchemaVersion}; legacy compatibility is withheld.";
+ return false;
+ }
+
+ var identityCompatibility = MmsDynamicReportQualificationProfilePolicy.CheckIdentityCompatibility(
+ profile,
+ currentIdentity);
+ if (!identityCompatibility.IsCompatible)
+ {
+ reason = identityCompatibility.Reason;
+ return false;
+ }
+
+ if (profile.State < MmsDynamicReportQualificationState.InformationReportProven)
+ {
+ reason = $"Dynamic qualification profile is {profile.State}; legacy compatibility requires InformationReportProven or stronger evidence.";
+ return false;
+ }
+
+ var envelope = profile.AcceptedEnvelope;
+ var activation = profile.RcbActivationProof;
+ if (envelope is null || activation is null || report is null)
+ {
+ reason = "InformationReportProven profile is missing accepted-envelope, activation, or InformationReport evidence.";
+ return false;
+ }
+
+ if (!activation.IsSuccess || !report.IsSuccess)
+ {
+ reason = "Stored activation/report evidence is unsuccessful; legacy compatibility is withheld.";
+ return false;
+ }
+
+ if (!SameRcb(activation.RcbReference, report.RcbReference))
+ {
+ reason = "Stored activation/report RCB identities differ.";
+ return false;
+ }
+
+ if (!SameRcb(activation.DataSetReference, report.DataSetReference))
+ {
+ reason = "Stored activation/report DataSet identities differ.";
+ return false;
+ }
+
+ if (!ExactMemberSequenceEquals(activation.MemberReferences, report.MemberReferences))
+ {
+ reason = "Stored activation/report member sequences differ.";
+ return false;
+ }
+
+ if (!IsOrderedMemberSubset(report.MemberReferences, envelope.ExactProvenMemberReferences))
+ {
+ reason = "Stored InformationReport members are outside the exact accepted envelope.";
+ return false;
+ }
+
+ if (report.MemberReferences.Count == 0 || report.MemberReferences.Count > envelope.ProvenMemberCount)
+ {
+ reason = "Stored InformationReport member count is outside the accepted envelope.";
+ return false;
+ }
+
+ if (evidence?.IsSuccess != true)
+ {
+ reason = $"Stored InformationReport kind is {report.Kind}; no complete physical legacy dchg compatibility evidence was supplied.";
+ return false;
+ }
+
+ if (!SameText(evidence.StableIdentityKey, currentIdentity.StableIdentityKey))
+ {
+ reason = "Legacy dchg compatibility stable identity does not match the current IED identity.";
+ return false;
+ }
+
+ if (!SameText(evidence.ModelFingerprint, currentIdentity.ModelFingerprint))
+ {
+ reason = "Legacy dchg compatibility model fingerprint does not match the current IED model.";
+ return false;
+ }
+
+ if (!SameText(evidence.ProfileRevision, currentIdentity.ProfileRevision))
+ {
+ reason = "Legacy dchg compatibility profile revision does not match the current IED profile revision.";
+ return false;
+ }
+
+ if (!SameRcb(evidence.RcbReference, report.RcbReference))
+ {
+ reason = "Legacy dchg compatibility RCB does not match the persisted proven RCB.";
+ return false;
+ }
+
+ if (!ExactMemberSequenceEquals(evidence.MemberReferences, report.MemberReferences))
+ {
+ reason = "Legacy dchg compatibility member sequence does not exactly match the persisted proven member sequence.";
+ return false;
+ }
+
+ // Compatibility view only. The original profile object is not modified or saved.
+ // The legacy evidence independently proves the later NO-GI dchg event on the same
+ // exact RCB/member envelope; the existing planner still validates the original
+ // activation/report DataSet chain plus fresh live availability before any write.
+ var compatibilityView = profile with
+ {
+ InformationReportProof = report with
+ {
+ EvidenceId = "legacy-compatibility-view:" + evidence.EvidenceId.Trim(),
+ Kind = MmsDynamicInformationReportKind.DataChange
+ }
+ };
+
+ compatibleContext = sourceContext with { Profile = compatibilityView };
+ reason =
+ "Legacy InformationReportProven compatibility accepted from complete physical NO-GI dchg evidence on the exact current identity, proven RCB, and ordered member sequence. Persisted profile remains unchanged and ProductionEligible remains separate.";
+ return true;
+ }
+
+ private static bool SameText(string? left, string? right)
+ => string.Equals((left ?? string.Empty).Trim(), (right ?? string.Empty).Trim(), StringComparison.OrdinalIgnoreCase);
+
+ private static bool SameRcb(string? left, string? right)
+ => string.Equals(
+ MmsRcbAvailabilityEvaluator.NormalizeReference(left).Replace('\\', '/'),
+ MmsRcbAvailabilityEvaluator.NormalizeReference(right).Replace('\\', '/'),
+ StringComparison.OrdinalIgnoreCase);
+
+ private static bool ExactMemberSequenceEquals(
+ IReadOnlyList left,
+ IReadOnlyList right)
+ {
+ if (left.Count != right.Count)
+ return false;
+
+ for (var index = 0; index < left.Count; index++)
+ {
+ if (!string.Equals(
+ MmsFcReferenceNormalizer.NormalizeMmsReference(left[index] ?? string.Empty),
+ MmsFcReferenceNormalizer.NormalizeMmsReference(right[index] ?? string.Empty),
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsOrderedMemberSubset(
+ IReadOnlyList subset,
+ IReadOnlyList full)
+ {
+ var searchIndex = 0;
+ foreach (var candidate in subset.Select(item => MmsFcReferenceNormalizer.NormalizeMmsReference(item ?? string.Empty)))
+ {
+ var found = false;
+ while (searchIndex < full.Count)
+ {
+ var fullCandidate = MmsFcReferenceNormalizer.NormalizeMmsReference(full[searchIndex] ?? string.Empty);
+ searchIndex++;
+ if (!string.Equals(candidate, fullCandidate, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ found = true;
+ break;
+ }
+
+ if (!found)
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacyCompatibilityTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacyCompatibilityTests.cs
new file mode 100644
index 0000000..610b448
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacyCompatibilityTests.cs
@@ -0,0 +1,352 @@
+using AR.Iec61850.Acse;
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsG26GuardedLegacyCompatibilityTests
+{
+ [Fact]
+ public void LegacyGiProfile_WithExactPhysicalDchgEvidence_BuildsGuardedCompatibilityView()
+ {
+ var legacyContext = LegacyContext();
+ var originalProof = legacyContext.Profile.InformationReportProof!;
+
+ var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext(
+ legacyContext,
+ Evidence(),
+ out var compatible,
+ out var reason);
+
+ Assert.True(accepted, reason);
+ Assert.Equal(MmsDynamicInformationReportKind.GeneralInterrogation, originalProof.Kind);
+ Assert.Equal(MmsDynamicInformationReportKind.DataChange, compatible.Profile.InformationReportProof!.Kind);
+ Assert.StartsWith("legacy-compatibility-view:", compatible.Profile.InformationReportProof.EvidenceId, StringComparison.Ordinal);
+ Assert.Contains("Persisted profile remains unchanged", reason, StringComparison.OrdinalIgnoreCase);
+
+ var plan = BuildPlan(compatible);
+ Assert.False(plan.AutomaticDynamicActivationQuarantined);
+ Assert.False(plan.ProductionDynamicActivationAuthorized);
+ Assert.Equal(2, plan.AcquisitionPlan.DynamicUrcbSignalCount);
+ Assert.Equal(0, plan.AcquisitionPlan.PollingFallbackSignalCount);
+ var dynamic = Assert.Single(plan.AcquisitionPlan.Segments, segment => segment.Kind == MmsHybridAcquisitionKind.DynamicUrcb);
+ Assert.Equal(ProvenRcbReference, dynamic.ReportControlReference);
+ Assert.Equal(Members(), dynamic.ReportPlan!.DynamicPoints.Select(point => point.MmsReference).ToArray());
+ }
+
+ [Fact]
+ public void LegacyGiProfile_WithoutCompatibilityEvidence_RemainsWithheld()
+ {
+ var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext(
+ LegacyContext(),
+ null,
+ out _,
+ out var reason);
+
+ Assert.False(accepted);
+ Assert.Contains("no complete physical legacy dchg compatibility evidence", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LegacyGiProfile_IdentityMismatch_RemainsWithheld()
+ {
+ var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext(
+ LegacyContext(),
+ Evidence() with { ModelFingerprint = "sha256:different" },
+ out _,
+ out var reason);
+
+ Assert.False(accepted);
+ Assert.Contains("fingerprint", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LegacyGiProfile_MemberMismatch_RemainsWithheld()
+ {
+ var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext(
+ LegacyContext(),
+ Evidence() with { MemberReferences = [Member(2), Member(1)] },
+ out _,
+ out var reason);
+
+ Assert.False(accepted);
+ Assert.Contains("member sequence", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LegacyGiProfile_IncompleteCleanupEvidence_RemainsWithheld()
+ {
+ var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext(
+ LegacyContext(),
+ Evidence() with { CleanupSucceeded = false },
+ out _,
+ out var reason);
+
+ Assert.False(accepted);
+ Assert.Contains("no complete physical legacy dchg compatibility evidence", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void StoredDataChangeProfile_DoesNotRequireLegacyEvidence()
+ {
+ var native = NativeContext();
+ var accepted = MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext(
+ native,
+ null,
+ out var compatible,
+ out var reason);
+
+ Assert.True(accepted, reason);
+ Assert.Same(native.Profile, compatible.Profile);
+ Assert.Contains("no legacy compatibility adaptation", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static MmsCapabilityAwareHybridReportAcquisitionPlan BuildPlan(
+ MmsDynamicReportGuardedRuntimePlanningContext context)
+ {
+ var signals = new[] { Signal(1), Signal(2) };
+ var catalog = new Iec61850SignalCatalogDocument
+ {
+ IedName = "G26_P15_IED",
+ Source = "P1.5 legacy compatibility fixture",
+ Signals = signals
+ };
+
+ return MmsGuardedDynamicReportRuntimePlanner.Build(
+ catalog,
+ signals,
+ DynamicInventory(),
+ DynamicAvailability(),
+ Directory(),
+ new AcseMmsNegotiatedCapabilities
+ {
+ IsDecoded = true,
+ SupportsWrite = true,
+ SupportsDefineNamedVariableList = true,
+ SupportsDeleteNamedVariableList = true
+ },
+ new MmsHybridReportAcquisitionOptions
+ {
+ AllowStaticBrcb = false,
+ AllowStaticUrcb = false,
+ AllowDynamicBrcb = false,
+ AllowDynamicUrcb = true,
+ AllowCallerOwnedReports = false,
+ AllowPollingFallback = true,
+ RequireExactAvailabilityEvidence = true
+ },
+ context);
+ }
+
+ private static MmsDynamicReportGuardedRuntimePlanningContext LegacyContext()
+ {
+ var native = NativeContext();
+ return native with
+ {
+ Profile = native.Profile with
+ {
+ InformationReportProof = native.Profile.InformationReportProof! with
+ {
+ Kind = MmsDynamicInformationReportKind.GeneralInterrogation
+ }
+ }
+ };
+ }
+
+ private static MmsDynamicReportGuardedRuntimePlanningContext NativeContext()
+ => new()
+ {
+ Profile = BuildProfile(),
+ CurrentIdentity = Identity()
+ };
+
+ private static MmsDynamicReportLegacyDataChangeCompatibilityEvidence Evidence()
+ => new()
+ {
+ EvidenceId = "field-a3-dchg-proof",
+ StableIdentityKey = Identity().StableIdentityKey,
+ ModelFingerprint = Identity().ModelFingerprint,
+ ProfileRevision = Identity().ProfileRevision,
+ RcbReference = ProvenRcbReference,
+ MemberReferences = Members(),
+ ActualInformationReportReceived = true,
+ DataChangeReasonVerified = true,
+ GeneralInterrogationDisabled = true,
+ ExactMemberMappingVerified = true,
+ AssociationHealthyAfterReport = true,
+ CleanupSucceeded = true
+ };
+
+ private static MmsDynamicReportQualificationProfile BuildProfile()
+ {
+ var members = Members();
+ var assessment = MmsDynamicDataSetQualificationLadder.Assess(
+ [
+ new MmsDynamicDataSetQualificationAttemptEvidence
+ {
+ AttemptId = "p15-envelope",
+ ObservedAtUtc = Time(1),
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ DefineRequestByteCount = 200,
+ NegotiatedMaxMmsPduSize = 65000,
+ RequestWithinKnownNegotiatedPdu = true,
+ IsSuccess = true,
+ FailureStage = MmsDynamicDataSetQualificationFailureStage.None,
+ DynamicMutationAttempted = true,
+ AssociationSurvived = true,
+ CleanupSucceeded = true
+ }
+ ]);
+ var envelope = MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope(assessment, "p15-envelope");
+ var profile = MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile(
+ Identity(),
+ envelope,
+ assessment,
+ new MmsDynamicReportCapacityEvidence
+ {
+ ObservedFreeBrcbSlots = 0,
+ ObservedFreeUrcbSlots = 1,
+ ObservedAtUtc = Time(2),
+ EvidenceId = "p15-capacity"
+ },
+ "p15-envelope-evidence",
+ Time(3));
+
+ var activated = MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof(
+ profile,
+ Identity(),
+ new MmsDynamicRcbActivationProof
+ {
+ EvidenceId = "p15-activation",
+ ObservedAtUtc = Time(4),
+ RcbReference = ProvenRcbReference,
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ FreshRcbAvailabilityVerified = true,
+ DataSetReadbackVerified = true,
+ RcbDataSetBindingAccepted = true,
+ RptEnaAccepted = true,
+ AssociationHealthyAfterActivation = true
+ });
+
+ return MmsDynamicReportQualificationProfilePolicy.RecordInformationReportProof(
+ activated,
+ Identity(),
+ new MmsDynamicInformationReportProof
+ {
+ EvidenceId = "p15-report",
+ ObservedAtUtc = Time(5),
+ RcbReference = ProvenRcbReference,
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ Kind = MmsDynamicInformationReportKind.DataChange,
+ ActualInformationReportReceived = true,
+ ReportIdentityVerified = true,
+ ExactMemberMappingVerified = true,
+ AssociationHealthyAfterReport = true,
+ ReportAuthoritativePointCount = members.Length
+ });
+ }
+
+ private static MmsDynamicReportIedIdentity Identity()
+ => new()
+ {
+ StableIdentityKey = "ied:g26:p15",
+ ModelFingerprint = "sha256:g26-p15-model",
+ Manufacturer = "Example",
+ Model = "P15IED",
+ FirmwareRevision = "1.0.0",
+ ProfileRevision = "cfg-p15"
+ };
+
+ private static MmsReportInventory DynamicInventory()
+ {
+ var inventory = new MmsReportInventory();
+ inventory.ReportControls.Add(new MmsReportControlCandidate
+ {
+ Domain = "LD0",
+ LogicalNode = "LLN0",
+ FunctionalConstraint = "RP",
+ Name = "Unbuffer01",
+ Reference = ProvenRcbReference,
+ Buffered = false,
+ DataSetReference = string.Empty,
+ DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded,
+ EnabledState = "false",
+ ReservationState = "false",
+ TriggerOptions = "dchg",
+ ReportId = "Unbuffer01",
+ ConfRev = "1"
+ });
+ return inventory;
+ }
+
+ private static MmsRcbAvailabilityResult DynamicAvailability()
+ => new()
+ {
+ CheckedAtUtc = Time(10),
+ ReportControls =
+ [
+ new MmsRcbAvailabilitySnapshot
+ {
+ Reference = ProvenRcbReference,
+ Domain = "LD0",
+ LogicalNode = "LLN0",
+ Name = "Unbuffer01",
+ Mode = "URCB",
+ Buffered = false,
+ DataSetReference = string.Empty,
+ DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded,
+ ReportId = "Unbuffer01",
+ ConfRev = "1",
+ EnabledState = "false",
+ ReservationState = "false",
+ TriggerOptions = "dchg",
+ DataSetDirectoryRead = false,
+ DataSetDirectorySuccess = false,
+ DataSetMemberCount = 0,
+ DataSetMembers = Array.Empty(),
+ Availability = MmsRcbOperationalAvailability.NoDataSet,
+ Confidence = MmsRcbAvailabilityConfidence.Exact,
+ Reason = "P1.5 exact empty URCB fixture",
+ Attributes = ["DatSet", "RptEna", "TrgOps", "Resv"]
+ }
+ ]
+ };
+
+ private static MmsIedModelDirectory Directory()
+ => new(new[] { 1, 2 }.Select(index => new MmsFcResolvedPoint
+ {
+ Domain = "LD0",
+ LogicalNode = "GGIO1",
+ FunctionalConstraint = "ST",
+ DataObjectPath = $"Ind{index}.stVal",
+ MmsItemName = $"GGIO1$ST$Ind{index}$stVal",
+ Source = "P1.5 synthetic live directory",
+ Confidence = 100
+ }));
+
+ private static Iec61850SignalDescriptor Signal(int index)
+ => new()
+ {
+ DesignReference = $"LD0/GGIO1.Ind{index}.stVal",
+ ObservedReference = $"LD0/GGIO1.Ind{index}.stVal",
+ CanonicalMmsReference = Member(index),
+ EffectiveMmsReference = Member(index),
+ PrimaryValueReference = $"LD0/GGIO1.Ind{index}.stVal",
+ PrimaryValueMmsReference = Member(index),
+ FunctionalConstraint = "ST",
+ SemanticRole = Iec61850DataAttributeSemanticRole.PrimaryValue,
+ IsOperationalCandidate = true,
+ ResolutionStatus = Iec61850SignalCatalogResolutionStatus.DesignAttribute,
+ LiveStatus = Iec61850DesignLiveStatus.Exact
+ };
+
+ private static string[] Members() => [Member(1), Member(2)];
+ private static string Member(int index) => $"LD0/GGIO1$ST$Ind{index}$stVal";
+ private static DateTimeOffset Time(int minutes) => DateTimeOffset.Parse("2026-08-28T00:00:00Z").AddMinutes(minutes);
+
+ private const string ProvenRcbReference = "LD0/LLN0.RP.Unbuffer01";
+ private const string ProvenDataSetReference = "LD0/LLN0.AR_G2Q";
+}