diff --git a/src/AR.Iec61850/Mms/MmsG26GuardedLegacySubsetRuntimePlanner.cs b/src/AR.Iec61850/Mms/MmsG26GuardedLegacySubsetRuntimePlanner.cs
new file mode 100644
index 0000000..2c95aac
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsG26GuardedLegacySubsetRuntimePlanner.cs
@@ -0,0 +1,497 @@
+using AR.Iec61850.Acse;
+using AR.Iec61850.Discovery;
+
+namespace AR.Iec61850.Mms;
+
+///
+/// P1.5b validator for legacy InformationReportProven profiles whose persisted report chain
+/// covers a broader ordered member set than the later physical NO-GI dchg proof.
+///
+/// The persisted qualification profile is never rewritten. The later dchg evidence may
+/// authorize only its own exact ordered member subset when that subset is contained in both
+/// the stored successful report sequence and the accepted qualification envelope.
+///
+public static class MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy
+{
+ public static bool TryValidate(
+ MmsDynamicReportGuardedRuntimePlanningContext sourceContext,
+ MmsDynamicReportLegacyDataChangeCompatibilityEvidence? evidence,
+ out string reason)
+ {
+ ArgumentNullException.ThrowIfNull(sourceContext);
+
+ var profile = sourceContext.Profile;
+ var currentIdentity = sourceContext.CurrentIdentity;
+ var envelope = profile.AcceptedEnvelope;
+ var activation = profile.RcbActivationProof;
+ var report = profile.InformationReportProof;
+
+ if (profile.SchemaVersion != MmsDynamicReportQualificationProfile.CurrentSchemaVersion)
+ {
+ reason = $"Unsupported dynamic qualification profile schema {profile.SchemaVersion}; P1.5b 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}; P1.5b requires InformationReportProven or stronger evidence.";
+ return false;
+ }
+
+ 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; P1.5b compatibility is withheld.";
+ return false;
+ }
+
+ if (report.Kind != MmsDynamicInformationReportKind.GeneralInterrogation)
+ {
+ reason = $"P1.5b subset compatibility applies only to the reviewed GI-classified legacy chain; stored kind is {report.Kind}.";
+ 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 = "No complete physical NO-GI 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;
+ }
+
+ var normalizedEvidenceMembers = evidence.MemberReferences
+ .Select(NormalizeMms)
+ .ToArray();
+ if (normalizedEvidenceMembers.Any(string.IsNullOrWhiteSpace) ||
+ normalizedEvidenceMembers.Distinct(StringComparer.OrdinalIgnoreCase).Count() != normalizedEvidenceMembers.Length)
+ {
+ reason = "Legacy dchg compatibility member evidence is empty or duplicated.";
+ return false;
+ }
+
+ if (!IsOrderedMemberSubset(evidence.MemberReferences, report.MemberReferences))
+ {
+ reason = "Legacy dchg compatibility members are not an ordered subset of the persisted successful report sequence.";
+ return false;
+ }
+
+ if (!IsOrderedMemberSubset(evidence.MemberReferences, envelope.ExactProvenMemberReferences))
+ {
+ reason = "Legacy dchg compatibility members are outside the accepted qualification envelope.";
+ return false;
+ }
+
+ reason =
+ "P1.5b legacy subset compatibility accepted: the later physical NO-GI dchg members are an exact ordered subset of the unchanged persisted successful report chain and accepted envelope. ProductionEligible remains separate.";
+ return true;
+ }
+
+ internal static bool SameRcb(string? left, string? right)
+ => string.Equals(
+ MmsRcbAvailabilityEvaluator.NormalizeReference(left).Replace('\\', '/'),
+ MmsRcbAvailabilityEvaluator.NormalizeReference(right).Replace('\\', '/'),
+ StringComparison.OrdinalIgnoreCase);
+
+ internal 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(NormalizeMms(left[index]), NormalizeMms(right[index]), StringComparison.OrdinalIgnoreCase))
+ return false;
+ }
+
+ return true;
+ }
+
+ internal static bool IsOrderedMemberSubset(
+ IReadOnlyList subset,
+ IReadOnlyList full)
+ {
+ var searchIndex = 0;
+ foreach (var candidate in subset.Select(NormalizeMms))
+ {
+ var found = false;
+ while (searchIndex < full.Count)
+ {
+ var fullCandidate = NormalizeMms(full[searchIndex]);
+ searchIndex++;
+ if (!string.Equals(candidate, fullCandidate, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ found = true;
+ break;
+ }
+
+ if (!found)
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool SameText(string? left, string? right)
+ => string.Equals((left ?? string.Empty).Trim(), (right ?? string.Empty).Trim(), StringComparison.OrdinalIgnoreCase);
+
+ internal static string NormalizeMms(string? reference)
+ => MmsFcReferenceNormalizer.NormalizeMmsReference(reference ?? string.Empty);
+}
+
+///
+/// Guarded runtime planner for P1.5b legacy subset compatibility.
+///
+/// Static reporting keeps precedence. The one reviewed dynamic RCB may be used only for the
+/// later physically proven dchg subset. The broader persisted GI-classified member sequence
+/// remains qualification evidence only and is never promoted to dchg authority.
+///
+public static class MmsGuardedDynamicReportLegacySubsetRuntimePlanner
+{
+ public static MmsCapabilityAwareHybridReportAcquisitionPlan Build(
+ Iec61850SignalCatalogDocument catalog,
+ IEnumerable requestedSignals,
+ MmsReportInventory inventory,
+ MmsRcbAvailabilityResult availability,
+ MmsIedModelDirectory liveDirectory,
+ AcseMmsNegotiatedCapabilities? negotiatedCapabilities,
+ MmsHybridReportAcquisitionOptions? options,
+ MmsDynamicReportGuardedRuntimePlanningContext sourceContext,
+ MmsDynamicReportLegacyDataChangeCompatibilityEvidence evidence)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ ArgumentNullException.ThrowIfNull(requestedSignals);
+ ArgumentNullException.ThrowIfNull(inventory);
+ ArgumentNullException.ThrowIfNull(availability);
+ ArgumentNullException.ThrowIfNull(liveDirectory);
+ ArgumentNullException.ThrowIfNull(sourceContext);
+ ArgumentNullException.ThrowIfNull(evidence);
+
+ options ??= new MmsHybridReportAcquisitionOptions();
+ var capability = MmsReportAssociationCapabilityEvaluator.Evaluate(
+ availability,
+ negotiatedCapabilities,
+ options);
+ var dynamicIntent = options.AllowDynamicBrcb || options.AllowDynamicUrcb;
+ var compatibilityReason = string.Empty;
+
+ var authorized = dynamicIntent &&
+ capability.MayAttemptDynamicReports &&
+ MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate(
+ sourceContext,
+ evidence,
+ out compatibilityReason);
+
+ if (!dynamicIntent)
+ compatibilityReason = "Dynamic BRCB/URCB acquisition is disabled by planner policy.";
+ else if (!capability.MayAttemptDynamicReports)
+ compatibilityReason = "The current MMS association does not satisfy the dynamic-report capability gate.";
+
+ var configuredStaticReferences = availability.ReportControls
+ .Where(HasConfiguredStaticDataSetEvidence)
+ .Select(snapshot => Normalize(snapshot.Reference))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var allowedRcbReferences = configuredStaticReferences.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (authorized)
+ allowedRcbReferences.Add(Normalize(evidence.RcbReference));
+
+ var restrictedAvailability = RestrictAvailability(availability, allowedRcbReferences, capability.Warnings);
+ var restrictedInventory = RestrictInventory(inventory, allowedRcbReferences);
+ var planningDirectory = authorized
+ ? RestrictDirectoryToMembers(liveDirectory, evidence.MemberReferences)
+ : liveDirectory;
+ var effectiveOptions = BuildOptions(options, authorized, evidence.MemberReferences.Count);
+
+ var plan = MmsHybridReportAcquisitionPlanner.Build(
+ catalog,
+ requestedSignals,
+ restrictedInventory,
+ restrictedAvailability,
+ planningDirectory,
+ effectiveOptions);
+
+ var policyWarnings = new List();
+ if (dynamicIntent && capability.MayAttemptDynamicReports && !authorized)
+ {
+ policyWarnings.Add($"P1.5b guarded legacy subset dynamic reporting remains withheld: {compatibilityReason}");
+ }
+
+ if (authorized && !ValidateDynamicSegments(plan, evidence, out var invariantFailure))
+ {
+ policyWarnings.Add(
+ $"P1.5b guarded dynamic plan failed the exact physical dchg-subset invariant and was withheld: {invariantFailure}");
+ authorized = false;
+ compatibilityReason = invariantFailure;
+
+ var staticAvailability = RestrictAvailability(availability, configuredStaticReferences, capability.Warnings);
+ var staticInventory = RestrictInventory(inventory, configuredStaticReferences);
+ plan = MmsHybridReportAcquisitionPlanner.Build(
+ catalog,
+ requestedSignals,
+ staticInventory,
+ staticAvailability,
+ liveDirectory,
+ BuildOptions(options, false, 0));
+ }
+
+ RestoreFreshAttributeEvidence(plan, availability, authorized);
+
+ return new MmsCapabilityAwareHybridReportAcquisitionPlan
+ {
+ AcquisitionPlan = plan,
+ AssociationCapability = capability,
+ AutomaticDynamicActivationQuarantined =
+ capability.MayAttemptDynamicReports && dynamicIntent && !authorized,
+ ProductionDynamicActivationAuthorized = false,
+ ProductionDynamicAuthorizationReason = authorized
+ ? "P1.5b guarded legacy subset runtime authorization is active only for the separately proven NO-GI dchg subset; ProductionEligible certification remains separate."
+ : compatibilityReason,
+ ProductionQualifiedDynamicMemberCount = 0,
+ ProductionQualifiedRcbReference = string.Empty,
+ PolicyWarnings = policyWarnings.ToArray()
+ };
+ }
+
+ private static MmsHybridReportAcquisitionOptions BuildOptions(
+ MmsHybridReportAcquisitionOptions source,
+ bool authorized,
+ int provenMemberCount)
+ => new()
+ {
+ MaxStaticReportPlans = source.MaxStaticReportPlans,
+ MaxDynamicReportPlans = authorized ? Math.Min(source.MaxDynamicReportPlans, 1) : source.MaxDynamicReportPlans,
+ MaxDynamicMembersPerReport = authorized
+ ? Math.Min(source.MaxDynamicMembersPerReport, provenMemberCount)
+ : source.MaxDynamicMembersPerReport,
+ RequireExactAvailabilityEvidence = source.RequireExactAvailabilityEvidence,
+ AllowCallerOwnedReports = source.AllowCallerOwnedReports,
+ AllowStaticBrcb = source.AllowStaticBrcb,
+ AllowStaticUrcb = source.AllowStaticUrcb,
+ AllowDynamicBrcb = authorized && source.AllowDynamicBrcb,
+ AllowDynamicUrcb = authorized && source.AllowDynamicUrcb,
+ AllowPollingFallback = source.AllowPollingFallback
+ };
+
+ private static bool ValidateDynamicSegments(
+ MmsHybridReportAcquisitionPlan plan,
+ MmsDynamicReportLegacyDataChangeCompatibilityEvidence evidence,
+ out string reason)
+ {
+ var dynamicSegments = plan.Segments
+ .Where(segment => segment.Kind is MmsHybridAcquisitionKind.DynamicBrcb or MmsHybridAcquisitionKind.DynamicUrcb)
+ .ToArray();
+
+ if (dynamicSegments.Length == 0)
+ {
+ reason = "No guarded dynamic segment was needed or safely available for this request.";
+ return true;
+ }
+
+ if (dynamicSegments.Length > 1)
+ {
+ reason = "P1.5b guarded runtime is limited to one exact proven dynamic RCB group.";
+ return false;
+ }
+
+ var segment = dynamicSegments[0];
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.SameRcb(
+ segment.ReportControlReference,
+ evidence.RcbReference))
+ {
+ reason = $"Planner selected RCB {segment.ReportControlReference} instead of physical dchg-proven RCB {evidence.RcbReference}.";
+ return false;
+ }
+
+ if (segment.ReportPlan is null || segment.ReportPlan.DynamicPoints.Count == 0)
+ {
+ reason = "Dynamic segment is missing an exact resolved P1.5b member subset.";
+ return false;
+ }
+
+ var plannedMembers = segment.ReportPlan.DynamicPoints
+ .Select(point => MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.NormalizeMms(point.MmsReference))
+ .ToArray();
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.IsOrderedMemberSubset(
+ plannedMembers,
+ evidence.MemberReferences))
+ {
+ reason = "Dynamic segment member order/content is outside the later physically proven dchg subset.";
+ return false;
+ }
+
+ reason = "Dynamic segment remains inside the exact P1.5b physical dchg subset.";
+ return true;
+ }
+
+ private static MmsRcbAvailabilityResult RestrictAvailability(
+ MmsRcbAvailabilityResult availability,
+ IReadOnlySet allowedReferences,
+ IReadOnlyList capabilityWarnings)
+ => new()
+ {
+ CheckedAtUtc = availability.CheckedAtUtc,
+ ReportControls = availability.ReportControls
+ .Where(snapshot => allowedReferences.Contains(Normalize(snapshot.Reference)))
+ .ToArray(),
+ Warnings = availability.Warnings
+ .Concat(capabilityWarnings)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray()
+ };
+
+ private static MmsReportInventory RestrictInventory(
+ MmsReportInventory inventory,
+ IReadOnlySet allowedReferences)
+ {
+ var restricted = new MmsReportInventory();
+ restricted.DataSets.AddRange(inventory.DataSets);
+ restricted.ReportControls.AddRange(inventory.ReportControls
+ .Where(candidate => allowedReferences.Contains(Normalize(candidate.Reference))));
+ return restricted;
+ }
+
+ private static MmsIedModelDirectory RestrictDirectoryToMembers(
+ MmsIedModelDirectory liveDirectory,
+ IReadOnlyList members)
+ {
+ var allowed = members
+ .Select(MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.NormalizeMms)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ return new MmsIedModelDirectory(liveDirectory.Points
+ .Where(point => allowed.Contains(
+ MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.NormalizeMms(point.MmsReference))));
+ }
+
+ private static bool HasConfiguredStaticDataSetEvidence(MmsRcbAvailabilitySnapshot snapshot)
+ => snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded &&
+ snapshot.DataSetDirectorySuccess &&
+ snapshot.DataSetMembers.Count > 0 &&
+ !string.IsNullOrWhiteSpace(snapshot.DataSetReference);
+
+ private static void RestoreFreshAttributeEvidence(
+ MmsHybridReportAcquisitionPlan plan,
+ MmsRcbAvailabilityResult availability,
+ bool guardedDynamicAuthorized)
+ {
+ foreach (var segment in plan.Segments.Where(segment => segment.IsReportBacked && segment.ReportPlan?.ReportControl is not null))
+ {
+ var candidate = segment.ReportPlan!.ReportControl!;
+ var snapshot = availability.ReportControls.FirstOrDefault(item =>
+ Normalize(item.Reference).Equals(Normalize(candidate.Reference), StringComparison.OrdinalIgnoreCase));
+ if (snapshot is null)
+ continue;
+
+ var attributes = snapshot.Attributes.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ AddIfExposed(attributes, snapshot, "DatSet", snapshot.DataSetReference,
+ snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded);
+ AddIfExposed(attributes, snapshot, "RptEna", snapshot.EnabledState);
+ AddIfExposed(attributes, snapshot, "TrgOps", snapshot.TriggerOptions);
+ AddIfExposed(attributes, snapshot, "OptFlds", snapshot.OptionalFields);
+ AddIfExposed(attributes, snapshot, "IntgPd", snapshot.IntegrityPeriodMs);
+ AddIfExposed(attributes, snapshot, "GI", string.Empty);
+ AddIfExposed(
+ attributes,
+ snapshot,
+ snapshot.Buffered ? "ResvTms" : "Resv",
+ snapshot.Buffered ? snapshot.ReservationTimeSeconds : snapshot.ReservationState);
+
+ candidate.Attributes = attributes.OrderBy(value => value, StringComparer.OrdinalIgnoreCase).ToList();
+ candidate.ProbeDiagnostics.Clear();
+ candidate.ProbeDiagnostics.AddRange(snapshot.ProbeDiagnostics);
+ candidate.Status = segment.Kind is MmsHybridAcquisitionKind.StaticBrcb or MmsHybridAcquisitionKind.StaticUrcb
+ ? "P6.1 baseline-static fresh availability snapshot"
+ : guardedDynamicAuthorized
+ ? "G2.6 P1.5b legacy-subset guarded-runtime dynamic fresh availability snapshot"
+ : "G2.6 dynamic fresh availability snapshot";
+ }
+ }
+
+ private static void AddIfExposed(
+ ISet attributes,
+ MmsRcbAvailabilitySnapshot snapshot,
+ string attribute,
+ string value,
+ bool force = false)
+ {
+ if (force ||
+ (!string.IsNullOrWhiteSpace(value) && value.Trim() != "-") ||
+ snapshot.ProbeDiagnostics.Any(line =>
+ line.StartsWith(attribute, StringComparison.OrdinalIgnoreCase) &&
+ line.Contains(": OK", StringComparison.OrdinalIgnoreCase)))
+ {
+ attributes.Add(attribute);
+ }
+ }
+
+ private static string Normalize(string? reference)
+ => MmsRcbAvailabilityEvaluator.NormalizeReference(reference).Replace('\\', '/');
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacySubsetRuntimeTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacySubsetRuntimeTests.cs
new file mode 100644
index 0000000..c2cd3fc
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedLegacySubsetRuntimeTests.cs
@@ -0,0 +1,377 @@
+using AR.Iec61850.Acse;
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsG26GuardedLegacySubsetRuntimeTests
+{
+ [Fact]
+ public void LegacyGiProfile_BroaderStoredChain_ExactPhysicalDchgSubset_IsAuthorizedOnlyForSubset()
+ {
+ var context = LegacyContext();
+ var originalReport = context.Profile.InformationReportProof!;
+ var evidence = Evidence();
+
+ var valid = MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate(
+ context,
+ evidence,
+ out var reason);
+
+ Assert.True(valid, reason);
+ Assert.Equal(MmsDynamicInformationReportKind.GeneralInterrogation, originalReport.Kind);
+ Assert.Equal(4, originalReport.MemberReferences.Count);
+ Assert.Equal(2, evidence.MemberReferences.Count);
+ Assert.Contains("ordered subset", reason, StringComparison.OrdinalIgnoreCase);
+
+ var plan = BuildPlan(context, evidence);
+ 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(DchgMembers(), dynamic.ReportPlan!.DynamicPoints.Select(point => point.MmsReference).ToArray());
+
+ // P1.5b never rewrites the persisted GI-classified profile into a dchg proof.
+ Assert.Equal(MmsDynamicInformationReportKind.GeneralInterrogation, context.Profile.InformationReportProof!.Kind);
+ Assert.Equal(StoredMembers(), context.Profile.InformationReportProof.MemberReferences);
+ }
+
+ [Fact]
+ public void LegacyGiProfile_ReversedDchgSubset_RemainsWithheld()
+ {
+ var evidence = Evidence() with { MemberReferences = [Member(2), Member(1)] };
+
+ var valid = MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate(
+ LegacyContext(),
+ evidence,
+ out var reason);
+
+ Assert.False(valid);
+ Assert.Contains("ordered subset", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LegacyGiProfile_DchgMemberOutsideStoredChain_RemainsWithheld()
+ {
+ var evidence = Evidence() with { MemberReferences = [Member(1), Member(5)] };
+
+ var valid = MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate(
+ LegacyContext(),
+ evidence,
+ out var reason);
+
+ Assert.False(valid);
+ Assert.Contains("persisted successful report sequence", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LegacyGiProfile_StoredActivationAndReportSequenceMismatch_RemainsWithheld()
+ {
+ var context = LegacyContext();
+ var profile = context.Profile;
+ var activation = profile.RcbActivationProof! with
+ {
+ MemberReferences = [Member(1), Member(3), Member(2), Member(4)]
+ };
+ context = context with { Profile = profile with { RcbActivationProof = activation } };
+
+ var valid = MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate(
+ context,
+ Evidence(),
+ out var reason);
+
+ Assert.False(valid);
+ Assert.Contains("activation/report member sequences differ", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LegacyGiProfile_IdentityMismatch_RemainsWithheld()
+ {
+ var evidence = Evidence() with { ProfileRevision = "other-revision" };
+
+ var valid = MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate(
+ LegacyContext(),
+ evidence,
+ out var reason);
+
+ Assert.False(valid);
+ Assert.Contains("profile revision", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LegacyGiProfile_IncompletePhysicalEvidence_RemainsWithheld()
+ {
+ var evidence = Evidence() with { GeneralInterrogationDisabled = false };
+
+ var valid = MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate(
+ LegacyContext(),
+ evidence,
+ out var reason);
+
+ Assert.False(valid);
+ Assert.Contains("complete physical", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LegacySubsetPlanner_NeverAuthorizesProductionEligible()
+ {
+ var plan = BuildPlan(LegacyContext(), Evidence());
+
+ Assert.False(plan.ProductionDynamicActivationAuthorized);
+ Assert.Equal(0, plan.ProductionQualifiedDynamicMemberCount);
+ Assert.Equal(string.Empty, plan.ProductionQualifiedRcbReference);
+ Assert.Contains("ProductionEligible certification remains separate", plan.ProductionDynamicAuthorizationReason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static MmsCapabilityAwareHybridReportAcquisitionPlan BuildPlan(
+ MmsDynamicReportGuardedRuntimePlanningContext context,
+ MmsDynamicReportLegacyDataChangeCompatibilityEvidence evidence)
+ {
+ var signals = new[] { Signal(1), Signal(2) };
+ var catalog = new Iec61850SignalCatalogDocument
+ {
+ IedName = "G26_P15B_IED",
+ Source = "P1.5b subset fixture",
+ Signals = signals
+ };
+
+ return MmsGuardedDynamicReportLegacySubsetRuntimePlanner.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,
+ evidence);
+ }
+
+ private static MmsDynamicReportGuardedRuntimePlanningContext LegacyContext()
+ => new()
+ {
+ Profile = BuildLegacyProfile(),
+ CurrentIdentity = Identity()
+ };
+
+ private static MmsDynamicReportLegacyDataChangeCompatibilityEvidence Evidence()
+ => new()
+ {
+ EvidenceId = "field-a3-dchg-subset",
+ StableIdentityKey = Identity().StableIdentityKey,
+ ModelFingerprint = Identity().ModelFingerprint,
+ ProfileRevision = Identity().ProfileRevision,
+ RcbReference = ProvenRcbReference,
+ MemberReferences = DchgMembers(),
+ ActualInformationReportReceived = true,
+ DataChangeReasonVerified = true,
+ GeneralInterrogationDisabled = true,
+ ExactMemberMappingVerified = true,
+ AssociationHealthyAfterReport = true,
+ CleanupSucceeded = true
+ };
+
+ private static MmsDynamicReportQualificationProfile BuildLegacyProfile()
+ {
+ var members = StoredMembers();
+ var assessment = MmsDynamicDataSetQualificationLadder.Assess(
+ [
+ new MmsDynamicDataSetQualificationAttemptEvidence
+ {
+ AttemptId = "p15b-envelope",
+ ObservedAtUtc = Time(1),
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ DefineRequestByteCount = 260,
+ NegotiatedMaxMmsPduSize = 65000,
+ RequestWithinKnownNegotiatedPdu = true,
+ IsSuccess = true,
+ FailureStage = MmsDynamicDataSetQualificationFailureStage.None,
+ DynamicMutationAttempted = true,
+ AssociationSurvived = true,
+ CleanupSucceeded = true
+ }
+ ]);
+ var envelope = MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope(assessment, "p15b-envelope");
+ var profile = MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile(
+ Identity(),
+ envelope,
+ assessment,
+ new MmsDynamicReportCapacityEvidence
+ {
+ ObservedFreeBrcbSlots = 0,
+ ObservedFreeUrcbSlots = 1,
+ ObservedAtUtc = Time(2),
+ EvidenceId = "p15b-capacity"
+ },
+ "p15b-envelope-evidence",
+ Time(3));
+
+ var activated = MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof(
+ profile,
+ Identity(),
+ new MmsDynamicRcbActivationProof
+ {
+ EvidenceId = "p15b-activation",
+ ObservedAtUtc = Time(4),
+ RcbReference = ProvenRcbReference,
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ FreshRcbAvailabilityVerified = true,
+ DataSetReadbackVerified = true,
+ RcbDataSetBindingAccepted = true,
+ RptEnaAccepted = true,
+ AssociationHealthyAfterActivation = true
+ });
+
+ var report = MmsDynamicReportQualificationProfilePolicy.RecordInformationReportProof(
+ activated,
+ Identity(),
+ new MmsDynamicInformationReportProof
+ {
+ EvidenceId = "p15b-legacy-gi-report",
+ ObservedAtUtc = Time(5),
+ RcbReference = ProvenRcbReference,
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ Kind = MmsDynamicInformationReportKind.DataChange,
+ ActualInformationReportReceived = true,
+ ReportIdentityVerified = true,
+ ExactMemberMappingVerified = true,
+ AssociationHealthyAfterReport = true,
+ ReportAuthoritativePointCount = members.Length
+ });
+
+ // Model the historical persisted profile: successful InformationReportProven chain,
+ // but the stored discriminator predates the later explicit NO-GI dchg proof.
+ return report with
+ {
+ InformationReportProof = report.InformationReportProof! with
+ {
+ Kind = MmsDynamicInformationReportKind.GeneralInterrogation
+ }
+ };
+ }
+
+ private static MmsDynamicReportIedIdentity Identity()
+ => new()
+ {
+ StableIdentityKey = "ied:g26:p15b",
+ ModelFingerprint = "sha256:g26-p15b-model",
+ Manufacturer = "Example",
+ Model = "P15BIED",
+ FirmwareRevision = "1.0.0",
+ ProfileRevision = "cfg-p15b"
+ };
+
+ 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.5b exact empty URCB fixture",
+ Attributes = ["DatSet", "RptEna", "TrgOps", "Resv"]
+ }
+ ]
+ };
+
+ private static MmsIedModelDirectory Directory()
+ => new(Enumerable.Range(1, 4).Select(index => new MmsFcResolvedPoint
+ {
+ Domain = "LD0",
+ LogicalNode = "GGIO1",
+ FunctionalConstraint = "ST",
+ DataObjectPath = $"Ind{index}.stVal",
+ MmsItemName = $"GGIO1$ST$Ind{index}$stVal",
+ Source = "P1.5b 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[] StoredMembers() => [Member(1), Member(2), Member(3), Member(4)];
+ private static string[] DchgMembers() => [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";
+}