From b8fbe2d60424d8fcb702e4ce89af4bbf259546a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:37:22 +0700 Subject: [PATCH 1/2] G2.6: add guarded InformationReportProven dynamic runtime planner --- .../MmsGuardedDynamicReportRuntimePlanner.cs | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsGuardedDynamicReportRuntimePlanner.cs diff --git a/src/AR.Iec61850/Mms/MmsGuardedDynamicReportRuntimePlanner.cs b/src/AR.Iec61850/Mms/MmsGuardedDynamicReportRuntimePlanner.cs new file mode 100644 index 0000000..23de270 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsGuardedDynamicReportRuntimePlanner.cs @@ -0,0 +1,427 @@ +using AR.Iec61850.Acse; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Mms; + +/// +/// Explicit application-supplied evidence for guarded automatic dynamic reporting. +/// This is intentionally separate from ProductionEligible certification: a successful, +/// identity-compatible InformationReportProven profile may authorize only the exact +/// RCB/member envelope that has already produced a real data-change InformationReport. +/// +public sealed record MmsDynamicReportGuardedRuntimePlanningContext +{ + public MmsDynamicReportQualificationProfile Profile { get; init; } = new(); + public MmsDynamicReportIedIdentity CurrentIdentity { get; init; } = new(); +} + +/// +/// Guarded G2.6 runtime planner used after physical InformationReport proof but before any +/// optional ProductionEligible certification. Static reporting keeps precedence. Dynamic +/// reporting is limited to one exact proven RCB and an ordered subset of the exact proven +/// InformationReport member sequence. Anything outside that envelope remains on polling. +/// +public static class MmsGuardedDynamicReportRuntimePlanner +{ + public static MmsCapabilityAwareHybridReportAcquisitionPlan Build( + Iec61850SignalCatalogDocument catalog, + IEnumerable requestedSignals, + MmsReportInventory inventory, + MmsRcbAvailabilityResult availability, + MmsIedModelDirectory liveDirectory, + AcseMmsNegotiatedCapabilities? negotiatedCapabilities = null, + MmsHybridReportAcquisitionOptions? options = null, + MmsDynamicReportGuardedRuntimePlanningContext? guardedContext = null) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(requestedSignals); + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(availability); + ArgumentNullException.ThrowIfNull(liveDirectory); + + options ??= new MmsHybridReportAcquisitionOptions(); + var capability = MmsReportAssociationCapabilityEvaluator.Evaluate( + availability, + negotiatedCapabilities, + options); + var dynamicIntent = options.AllowDynamicBrcb || options.AllowDynamicUrcb; + var authorization = EvaluateGuardedAuthorization(guardedContext, capability, dynamicIntent); + + // Static RCBs remain eligible exactly as in the normal capability-aware planner. + // The guarded dynamic path adds only the one already-proven empty/free RCB. + var configuredStaticReferences = availability.ReportControls + .Where(HasConfiguredStaticDataSetEvidence) + .Select(snapshot => Normalize(snapshot.Reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var allowedRcbReferences = configuredStaticReferences.ToHashSet(StringComparer.OrdinalIgnoreCase); + if (authorization.IsAuthorized) + allowedRcbReferences.Add(Normalize(authorization.RcbReference)); + + var restrictedAvailability = RestrictAvailability( + availability, + allowedRcbReferences, + capability.Warnings); + var restrictedInventory = RestrictInventory(inventory, allowedRcbReferences); + var planningDirectory = authorization.IsAuthorized + ? RestrictDirectoryToQualifiedMembers(liveDirectory, authorization.MemberReferences) + : liveDirectory; + + var automaticOptions = GuardedMonitoringOptions(options, authorization); + var plan = MmsHybridReportAcquisitionPlanner.Build( + catalog, + requestedSignals, + restrictedInventory, + restrictedAvailability, + planningDirectory, + automaticOptions); + + var policyWarnings = new List(); + if (dynamicIntent && capability.MayAttemptDynamicReports && !authorization.IsAuthorized) + { + policyWarnings.Add( + $"Guarded automatic dynamic reporting remains withheld: {authorization.Reason}"); + } + + // Treat persisted qualification JSON as untrusted input and re-check the emitted + // dynamic plan. A mismatch cannot broaden the proven scope; it falls back to the + // unchanged static -> polling plan. + if (authorization.IsAuthorized && + !ValidateGuardedDynamicSegments(plan, authorization, out var invariantFailure)) + { + policyWarnings.Add( + $"Guarded dynamic plan failed the exact proven-envelope invariant and was withheld: {invariantFailure}"); + authorization = GuardedDynamicAuthorization.Denied(invariantFailure); + + var staticAvailability = RestrictAvailability( + availability, + configuredStaticReferences, + capability.Warnings); + var staticInventory = RestrictInventory(inventory, configuredStaticReferences); + plan = MmsHybridReportAcquisitionPlanner.Build( + catalog, + requestedSignals, + staticInventory, + staticAvailability, + liveDirectory, + GuardedMonitoringOptions(options, authorization)); + } + + RestoreFreshAttributeEvidence(plan, availability, authorization.IsAuthorized); + + return new MmsCapabilityAwareHybridReportAcquisitionPlan + { + AcquisitionPlan = plan, + AssociationCapability = capability, + AutomaticDynamicActivationQuarantined = + capability.MayAttemptDynamicReports && + dynamicIntent && + !authorization.IsAuthorized, + // Guarded runtime authorization is deliberately not ProductionEligible. + ProductionDynamicActivationAuthorized = false, + ProductionDynamicAuthorizationReason = authorization.IsAuthorized + ? "Guarded InformationReportProven runtime authorization is active; ProductionEligible certification remains separate." + : authorization.Reason, + ProductionQualifiedDynamicMemberCount = 0, + ProductionQualifiedRcbReference = string.Empty, + PolicyWarnings = policyWarnings.ToArray() + }; + } + + private static GuardedDynamicAuthorization EvaluateGuardedAuthorization( + MmsDynamicReportGuardedRuntimePlanningContext? context, + MmsReportAssociationCapability capability, + bool dynamicIntent) + { + if (!dynamicIntent) + return GuardedDynamicAuthorization.Denied("Dynamic BRCB/URCB acquisition is disabled by planner policy."); + if (context is null) + return GuardedDynamicAuthorization.Denied("No InformationReportProven guarded-runtime context was supplied."); + if (!capability.MayAttemptDynamicReports) + { + return GuardedDynamicAuthorization.Denied( + "The current MMS association does not satisfy the dynamic-report capability gate."); + } + + var profile = context.Profile; + if (profile.SchemaVersion != MmsDynamicReportQualificationProfile.CurrentSchemaVersion) + { + return GuardedDynamicAuthorization.Denied( + $"Unsupported dynamic qualification profile schema {profile.SchemaVersion}; requalification is required."); + } + + var compatibility = MmsDynamicReportQualificationProfilePolicy.CheckIdentityCompatibility( + profile, + context.CurrentIdentity); + if (!compatibility.IsCompatible) + return GuardedDynamicAuthorization.Denied(compatibility.Reason); + + if (profile.State < MmsDynamicReportQualificationState.InformationReportProven) + { + return GuardedDynamicAuthorization.Denied( + $"Dynamic qualification profile is {profile.State}; guarded runtime requires InformationReportProven or stronger evidence."); + } + + var envelope = profile.AcceptedEnvelope; + var activation = profile.RcbActivationProof; + var report = profile.InformationReportProof; + if (envelope is null || activation is null || report is null) + return GuardedDynamicAuthorization.Denied("InformationReportProven profile is missing required dynamic qualification evidence."); + if (!activation.IsSuccess || !report.IsSuccess) + return GuardedDynamicAuthorization.Denied("Stored activation/report evidence is unsuccessful; guarded runtime is withheld."); + if (report.Kind != MmsDynamicInformationReportKind.DataChange) + { + return GuardedDynamicAuthorization.Denied( + $"Guarded runtime requires a proven data-change InformationReport; stored kind is {report.Kind}."); + } + if (!SameReference(activation.RcbReference, report.RcbReference)) + return GuardedDynamicAuthorization.Denied("Stored activation/report RCB identities differ."); + if (!SameReference(activation.DataSetReference, report.DataSetReference)) + return GuardedDynamicAuthorization.Denied("Stored activation/report DataSet identities differ."); + if (!ExactMemberSequenceEquals(activation.MemberReferences, report.MemberReferences)) + return GuardedDynamicAuthorization.Denied("Stored activation/report member sequences differ."); + if (!IsOrderedMemberSubset(report.MemberReferences, envelope.ExactProvenMemberReferences)) + return GuardedDynamicAuthorization.Denied("InformationReport members are outside the exact qualified envelope."); + if (report.MemberReferences.Count == 0 || report.MemberReferences.Count > envelope.ProvenMemberCount) + return GuardedDynamicAuthorization.Denied("InformationReport member count is outside the accepted qualified envelope."); + + var normalizedMembers = report.MemberReferences + .Select(NormalizeMms) + .ToArray(); + if (normalizedMembers.Any(string.IsNullOrWhiteSpace) || + normalizedMembers.Distinct(StringComparer.OrdinalIgnoreCase).Count() != normalizedMembers.Length) + { + return GuardedDynamicAuthorization.Denied("InformationReport guarded-runtime member evidence is empty or duplicated."); + } + + return new GuardedDynamicAuthorization( + true, + "Identity-compatible InformationReportProven data-change evidence authorizes guarded runtime on the exact proven RCB/member envelope.", + report.RcbReference.Trim(), + normalizedMembers); + } + + 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 RestrictDirectoryToQualifiedMembers( + MmsIedModelDirectory liveDirectory, + IReadOnlyList qualifiedMembers) + { + var allowed = qualifiedMembers + .Select(NormalizeMms) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return new MmsIedModelDirectory(liveDirectory.Points + .Where(point => allowed.Contains(NormalizeMms(point.MmsReference)))); + } + + private static MmsHybridReportAcquisitionOptions GuardedMonitoringOptions( + MmsHybridReportAcquisitionOptions source, + GuardedDynamicAuthorization authorization) + => new() + { + MaxStaticReportPlans = source.MaxStaticReportPlans, + MaxDynamicReportPlans = authorization.IsAuthorized + ? Math.Min(source.MaxDynamicReportPlans, 1) + : source.MaxDynamicReportPlans, + MaxDynamicMembersPerReport = authorization.IsAuthorized + ? Math.Min(source.MaxDynamicMembersPerReport, authorization.MemberReferences.Count) + : source.MaxDynamicMembersPerReport, + RequireExactAvailabilityEvidence = source.RequireExactAvailabilityEvidence, + AllowCallerOwnedReports = source.AllowCallerOwnedReports, + AllowStaticBrcb = source.AllowStaticBrcb, + AllowStaticUrcb = source.AllowStaticUrcb, + AllowDynamicBrcb = authorization.IsAuthorized && source.AllowDynamicBrcb, + AllowDynamicUrcb = authorization.IsAuthorized && source.AllowDynamicUrcb, + AllowPollingFallback = source.AllowPollingFallback + }; + + private static bool ValidateGuardedDynamicSegments( + MmsHybridReportAcquisitionPlan plan, + GuardedDynamicAuthorization authorization, + 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 = "Guarded runtime is limited to one exact proven dynamic RCB group."; + return false; + } + + var segment = dynamicSegments[0]; + if (!SameReference(segment.ReportControlReference, authorization.RcbReference)) + { + reason = $"Planner selected RCB {segment.ReportControlReference} instead of proven RCB {authorization.RcbReference}."; + return false; + } + if (segment.ReportPlan is null || segment.ReportPlan.DynamicPoints.Count == 0) + { + reason = "Dynamic segment is missing an exact resolved guarded-runtime member set."; + return false; + } + + var plannedMembers = segment.ReportPlan.DynamicPoints + .Select(point => NormalizeMms(point.MmsReference)) + .ToArray(); + if (!IsOrderedMemberSubset(plannedMembers, authorization.MemberReferences)) + { + reason = "Dynamic segment member order/content is not an ordered subset of the proven InformationReport member set."; + return false; + } + + reason = "Dynamic segment remains inside the exact InformationReportProven guarded-runtime evidence scope."; + return true; + } + + 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 InformationReportProven 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 bool SameReference(string? left, string? right) + => Normalize(left).Equals(Normalize(right), 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 (!NormalizeMms(left[index]).Equals(NormalizeMms(right[index]), StringComparison.OrdinalIgnoreCase)) + return false; + } + return true; + } + + private static bool IsOrderedMemberSubset( + IReadOnlyList subset, + IReadOnlyList full) + { + var searchIndex = 0; + foreach (var candidate in subset.Select(NormalizeMms)) + { + var found = false; + while (searchIndex < full.Count) + { + if (candidate.Equals(NormalizeMms(full[searchIndex]), StringComparison.OrdinalIgnoreCase)) + { + found = true; + searchIndex++; + break; + } + searchIndex++; + } + if (!found) + return false; + } + return true; + } + + private static string NormalizeMms(string? reference) + => MmsFcReferenceNormalizer.NormalizeMmsReference(reference ?? string.Empty); + + private static string Normalize(string? reference) + => MmsRcbAvailabilityEvaluator.NormalizeReference(reference).Replace('\\', '/'); + + private sealed record GuardedDynamicAuthorization( + bool IsAuthorized, + string Reason, + string RcbReference, + IReadOnlyList MemberReferences) + { + public static GuardedDynamicAuthorization Denied(string reason) + => new(false, reason, string.Empty, Array.Empty()); + } +} From 29a14645f8e4bbc99ccbad02eac8abf90b55d39f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:38:01 +0700 Subject: [PATCH 2/2] G2.6: cover guarded InformationReportProven runtime planning --- ...MmsG26GuardedDynamicRuntimePlannerTests.cs | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsG26GuardedDynamicRuntimePlannerTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedDynamicRuntimePlannerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedDynamicRuntimePlannerTests.cs new file mode 100644 index 0000000..cb9a693 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsG26GuardedDynamicRuntimePlannerTests.cs @@ -0,0 +1,371 @@ +using AR.Iec61850.Acse; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsG26GuardedDynamicRuntimePlannerTests +{ + [Fact] + public void InformationReportProven_ExactProvenRcb_AuthorizesGuardedDynamicRuntime() + { + var result = Build( + [Signal(1), Signal(2)], + DynamicAvailability(ProvenRcbReference), + DynamicInventory(ProvenRcbReference), + Directory(1, 2, 3, 4), + GuardedContext()); + + Assert.True(result.AssociationCapability.MayAttemptDynamicReports); + Assert.False(result.AutomaticDynamicActivationQuarantined); + Assert.False(result.ProductionDynamicActivationAuthorized); + Assert.Equal(2, result.AcquisitionPlan.DynamicUrcbSignalCount); + Assert.Equal(0, result.AcquisitionPlan.PollingFallbackSignalCount); + + var dynamic = Assert.Single( + result.AcquisitionPlan.Segments, + x => x.Kind == MmsHybridAcquisitionKind.DynamicUrcb); + Assert.Equal(ProvenRcbReference, dynamic.ReportControlReference); + Assert.Equal( + [Member(1), Member(2)], + dynamic.ReportPlan!.DynamicPoints.Select(point => point.MmsReference).ToArray()); + Assert.Contains("guarded", result.ProductionDynamicAuthorizationReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void InformationReportProven_UnprovenMember_RemainsOnPolling() + { + var result = Build( + [Signal(1), OutsideSignal()], + DynamicAvailability(ProvenRcbReference), + DynamicInventory(ProvenRcbReference), + Directory(1, 2, 3, 4, 99), + GuardedContext()); + + Assert.False(result.AutomaticDynamicActivationQuarantined); + Assert.Equal(1, result.AcquisitionPlan.DynamicUrcbSignalCount); + Assert.Equal(1, result.AcquisitionPlan.PollingFallbackSignalCount); + + var dynamic = Assert.Single( + result.AcquisitionPlan.Segments, + x => x.Kind == MmsHybridAcquisitionKind.DynamicUrcb); + Assert.Single(dynamic.ReportPlan!.DynamicPoints); + Assert.Equal(Member(1), dynamic.ReportPlan.DynamicPoints[0].MmsReference); + } + + [Fact] + public void InformationReportProven_IdentityMismatch_FailsClosedToPolling() + { + var context = GuardedContext() with + { + CurrentIdentity = Identity() with { ModelFingerprint = "sha256:different" } + }; + + var result = Build( + [Signal(1)], + DynamicAvailability(ProvenRcbReference), + DynamicInventory(ProvenRcbReference), + Directory(1), + context); + + Assert.True(result.AutomaticDynamicActivationQuarantined); + Assert.Equal(0, result.AcquisitionPlan.DynamicUrcbSignalCount); + Assert.Equal(1, result.AcquisitionPlan.PollingFallbackSignalCount); + Assert.Contains("fingerprint", result.ProductionDynamicAuthorizationReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void InformationReportProven_DifferentFreeRcb_IsNeverSubstituted() + { + const string otherRcb = "LD0/LLN0.RP.OtherFree01"; + var result = Build( + [Signal(1)], + DynamicAvailability(otherRcb), + DynamicInventory(otherRcb), + Directory(1), + GuardedContext()); + + Assert.Equal(0, result.AcquisitionPlan.DynamicUrcbSignalCount); + Assert.Equal(1, result.AcquisitionPlan.PollingFallbackSignalCount); + Assert.DoesNotContain(result.AcquisitionPlan.Segments, segment => + segment.Kind == MmsHybridAcquisitionKind.DynamicUrcb && + segment.ReportControlReference.Equals(otherRcb, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InformationReportProven_NonDataChangeProof_RemainsQuarantined() + { + var profile = BuildInformationReportProvenProfile(); + profile = profile with + { + InformationReportProof = profile.InformationReportProof! with + { + Kind = MmsDynamicInformationReportKind.GeneralInterrogation + } + }; + + var result = Build( + [Signal(1)], + DynamicAvailability(ProvenRcbReference), + DynamicInventory(ProvenRcbReference), + Directory(1), + new MmsDynamicReportGuardedRuntimePlanningContext + { + Profile = profile, + CurrentIdentity = Identity() + }); + + Assert.True(result.AutomaticDynamicActivationQuarantined); + Assert.Equal(0, result.AcquisitionPlan.DynamicUrcbSignalCount); + Assert.Equal(1, result.AcquisitionPlan.PollingFallbackSignalCount); + Assert.Contains("data-change", result.ProductionDynamicAuthorizationReason, StringComparison.OrdinalIgnoreCase); + } + + private static MmsCapabilityAwareHybridReportAcquisitionPlan Build( + IReadOnlyList signals, + MmsRcbAvailabilityResult availability, + MmsReportInventory inventory, + MmsIedModelDirectory directory, + MmsDynamicReportGuardedRuntimePlanningContext context) + { + var catalog = new Iec61850SignalCatalogDocument + { + IedName = "G26_GUARDED_IED", + Source = "Project-owned guarded runtime fixture", + Signals = signals + }; + + return MmsGuardedDynamicReportRuntimePlanner.Build( + catalog, + signals, + inventory, + availability, + 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 GuardedContext() + => new() + { + Profile = BuildInformationReportProvenProfile(), + CurrentIdentity = Identity() + }; + + private static MmsDynamicReportQualificationProfile BuildInformationReportProvenProfile() + { + var members = Members(1, 2, 3, 4); + var assessment = MmsDynamicDataSetQualificationLadder.Assess( + [ + new MmsDynamicDataSetQualificationAttemptEvidence + { + AttemptId = "g26-guarded-envelope-4", + ObservedAtUtc = Time(1), + DataSetReference = ProvenDataSetReference, + MemberReferences = members, + DefineRequestByteCount = 256, + NegotiatedMaxMmsPduSize = 65000, + RequestWithinKnownNegotiatedPdu = true, + IsSuccess = true, + FailureStage = MmsDynamicDataSetQualificationFailureStage.None, + DynamicMutationAttempted = true, + AssociationSurvived = true, + CleanupSucceeded = true + } + ]); + var envelope = MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope( + assessment, + "g26-guarded-envelope-4"); + var profile = MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile( + Identity(), + envelope, + assessment, + new MmsDynamicReportCapacityEvidence + { + ObservedFreeBrcbSlots = 0, + ObservedFreeUrcbSlots = 1, + ObservedAtUtc = Time(2), + EvidenceId = "g26-guarded-capacity" + }, + "g26-guarded-envelope-evidence", + Time(3)); + + var activated = MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof( + profile, + Identity(), + new MmsDynamicRcbActivationProof + { + EvidenceId = "g26-guarded-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 = "g26-guarded-information-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:guarded", + ModelFingerprint = "sha256:g26-guarded-model", + Manufacturer = "Example", + Model = "GuardedIED", + FirmwareRevision = "1.0.0", + ProfileRevision = "cfg-1" + }; + + private static MmsReportInventory DynamicInventory(string rcbReference) + { + var inventory = new MmsReportInventory(); + inventory.ReportControls.Add(new MmsReportControlCandidate + { + Domain = "LD0", + LogicalNode = "LLN0", + FunctionalConstraint = "RP", + Name = RcbName(rcbReference), + Reference = rcbReference, + Buffered = false, + DataSetReference = string.Empty, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + EnabledState = "false", + ReservationState = "false", + TriggerOptions = "dchg", + ReportId = RcbName(rcbReference), + ConfRev = "1" + }); + return inventory; + } + + private static MmsRcbAvailabilityResult DynamicAvailability(string rcbReference) + => new() + { + CheckedAtUtc = Time(40), + ReportControls = + [ + new MmsRcbAvailabilitySnapshot + { + Reference = rcbReference, + Domain = "LD0", + LogicalNode = "LLN0", + Name = RcbName(rcbReference), + Mode = "URCB", + Buffered = false, + DataSetReference = string.Empty, + DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded, + ReportId = RcbName(rcbReference), + ConfRev = "1", + EnabledState = "false", + ReservationState = "false", + TriggerOptions = "dchg", + DataSetDirectoryRead = false, + DataSetDirectorySuccess = false, + DataSetMemberCount = 0, + DataSetMembers = Array.Empty(), + Availability = MmsRcbOperationalAvailability.NoDataSet, + Confidence = MmsRcbAvailabilityConfidence.Exact, + Reason = "G2.6 guarded exact empty dynamic slot fixture", + Attributes = ["DatSet", "RptEna", "TrgOps", "Resv"] + } + ] + }; + + private static MmsIedModelDirectory Directory(params int[] indexes) + => new(indexes.Select(index => new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + DataObjectPath = index == 99 ? "Outside.stVal" : $"Ind{index}.stVal", + MmsItemName = index == 99 ? "GGIO1$ST$Outside$stVal" : $"GGIO1$ST$Ind{index}$stVal", + Source = "G2.6 guarded 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 Iec61850SignalDescriptor OutsideSignal() + => new() + { + DesignReference = "LD0/GGIO1.Outside.stVal", + ObservedReference = "LD0/GGIO1.Outside.stVal", + CanonicalMmsReference = Member(99), + EffectiveMmsReference = Member(99), + PrimaryValueReference = "LD0/GGIO1.Outside.stVal", + PrimaryValueMmsReference = Member(99), + FunctionalConstraint = "ST", + SemanticRole = Iec61850DataAttributeSemanticRole.PrimaryValue, + IsOperationalCandidate = true, + ResolutionStatus = Iec61850SignalCatalogResolutionStatus.DesignAttribute, + LiveStatus = Iec61850DesignLiveStatus.Exact + }; + + private static string[] Members(params int[] indexes) + => indexes.Select(Member).ToArray(); + + private static string Member(int index) + => index == 99 + ? "LD0/GGIO1$ST$Outside$stVal" + : $"LD0/GGIO1$ST$Ind{index}$stVal"; + + private static string RcbName(string reference) + => reference[(reference.LastIndexOf('.') + 1)..]; + + private static DateTimeOffset Time(int minutes) + => DateTimeOffset.Parse("2026-08-26T00:00:00Z").AddMinutes(minutes); + + private const string ProvenRcbReference = "LD0/LLN0.RP.Unbuffer02"; + private const string ProvenDataSetReference = "LD0/LLN0.AR_G2Q"; +}