diff --git a/src/AR.Iec61850/Mms/MmsG27NativeFieldCapabilityRuntimePlanner.cs b/src/AR.Iec61850/Mms/MmsG27NativeFieldCapabilityRuntimePlanner.cs
new file mode 100644
index 00000000..f247b757
--- /dev/null
+++ b/src/AR.Iec61850/Mms/MmsG27NativeFieldCapabilityRuntimePlanner.cs
@@ -0,0 +1,580 @@
+using AR.Iec61850.Acse;
+using AR.Iec61850.Discovery;
+
+namespace AR.Iec61850.Mms;
+
+///
+/// Durable application-supplied witness for a native, identity-compatible DataChange
+/// InformationReportProven profile. Unlike the legacy P1.5b compatibility adapter, this
+/// evidence is produced by the same per-IED commissioning transaction that records the
+/// persisted activation + DataChange InformationReport chain.
+///
+/// The witness deliberately retains cleanup evidence outside the qualification profile so
+/// general Dynamic RCB runtime cannot be unlocked by a DataChange profile alone. The engine
+/// requires exact identity/profile/activation/report/RCB/DataSet binding plus successful
+/// monitor cleanup, proof-field restore and fresh-association closure.
+///
+public sealed record MmsDynamicReportNativeFieldCapabilityEvidence
+{
+ public string EvidenceId { get; init; } = string.Empty;
+ public DateTimeOffset ObservedAtUtc { get; init; }
+ 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 string DataSetReference { get; init; } = string.Empty;
+ public string RcbActivationEvidenceId { get; init; } = string.Empty;
+ public string InformationReportEvidenceId { get; init; } = string.Empty;
+ public IReadOnlyList IncludedMemberReferences { 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 MonitorCleanupSucceeded { get; init; }
+ public bool ProofFieldRestoreSucceeded { get; init; }
+ public bool FreshCleanupClosureSucceeded { get; init; }
+
+ public bool IsSuccess =>
+ !string.IsNullOrWhiteSpace(EvidenceId) &&
+ ObservedAtUtc != default &&
+ !string.IsNullOrWhiteSpace(StableIdentityKey) &&
+ !string.IsNullOrWhiteSpace(ModelFingerprint) &&
+ !string.IsNullOrWhiteSpace(RcbReference) &&
+ !string.IsNullOrWhiteSpace(DataSetReference) &&
+ !string.IsNullOrWhiteSpace(RcbActivationEvidenceId) &&
+ !string.IsNullOrWhiteSpace(InformationReportEvidenceId) &&
+ IncludedMemberReferences.Count > 0 &&
+ ActualInformationReportReceived &&
+ DataChangeReasonVerified &&
+ GeneralInterrogationDisabled &&
+ ExactMemberMappingVerified &&
+ AssociationHealthyAfterReport &&
+ MonitorCleanupSucceeded &&
+ ProofFieldRestoreSucceeded &&
+ FreshCleanupClosureSucceeded;
+}
+
+///
+/// P1.7 native per-IED field-capability authorization policy.
+///
+/// A persisted DataChange InformationReport proof is necessary but deliberately insufficient:
+/// the separately persisted cleanup witness must bind back to the exact activation/report
+/// evidence IDs and exact RCB/DataSet on the current identity-compatible profile. Once that
+/// binding is accepted, the physical witness proves the dynamic reporting mechanism rather
+/// than permanently restricting runtime members to the commissioning DataSet.
+///
+public static class MmsGuardedDynamicReportNativeFieldCapabilityPolicy
+{
+ public static bool TryValidate(
+ MmsDynamicReportGuardedRuntimePlanningContext sourceContext,
+ MmsDynamicReportNativeFieldCapabilityEvidence? 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}; native field capability 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}; native field capability 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; native field capability is withheld.";
+ return false;
+ }
+
+ if (report.Kind != MmsDynamicInformationReportKind.DataChange)
+ {
+ reason = $"Native field capability requires a persisted DataChange InformationReport proof; stored kind is {report.Kind}.";
+ return false;
+ }
+
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.SameRcb(activation.RcbReference, report.RcbReference))
+ {
+ reason = "Stored activation/report RCB identities differ.";
+ return false;
+ }
+
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.SameRcb(activation.DataSetReference, report.DataSetReference))
+ {
+ reason = "Stored activation/report DataSet identities differ.";
+ return false;
+ }
+
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.ExactMemberSequenceEquals(
+ activation.MemberReferences,
+ report.MemberReferences))
+ {
+ reason = "Stored activation/report member sequences differ.";
+ return false;
+ }
+
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.IsOrderedMemberSubset(
+ report.MemberReferences,
+ envelope.ExactProvenMemberReferences))
+ {
+ reason = "Stored DataChange report members are outside the exact accepted envelope.";
+ return false;
+ }
+
+ if (report.MemberReferences.Count == 0 || report.MemberReferences.Count > envelope.ProvenMemberCount)
+ {
+ reason = "Stored DataChange report member count is outside the accepted envelope.";
+ return false;
+ }
+
+ if (evidence?.IsSuccess != true)
+ {
+ reason = "No complete native physical dchg + cleanup capability witness was supplied.";
+ return false;
+ }
+
+ if (!SameText(evidence.StableIdentityKey, currentIdentity.StableIdentityKey))
+ {
+ reason = "Native field-capability stable identity does not match the current IED identity.";
+ return false;
+ }
+
+ if (!SameText(evidence.ModelFingerprint, currentIdentity.ModelFingerprint))
+ {
+ reason = "Native field-capability model fingerprint does not match the current IED model.";
+ return false;
+ }
+
+ if (!SameText(evidence.ProfileRevision, currentIdentity.ProfileRevision))
+ {
+ reason = "Native field-capability profile revision does not match the current IED profile revision.";
+ return false;
+ }
+
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.SameRcb(evidence.RcbReference, report.RcbReference))
+ {
+ reason = "Native field-capability RCB does not match the persisted DataChange report RCB.";
+ return false;
+ }
+
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.SameRcb(evidence.DataSetReference, report.DataSetReference))
+ {
+ reason = "Native field-capability DataSet does not match the persisted DataChange report DataSet.";
+ return false;
+ }
+
+ if (!SameText(evidence.RcbActivationEvidenceId, activation.EvidenceId))
+ {
+ reason = "Native field-capability witness is not bound to the current persisted RCB activation evidence.";
+ return false;
+ }
+
+ if (!SameText(evidence.InformationReportEvidenceId, report.EvidenceId))
+ {
+ reason = "Native field-capability witness is not bound to the current persisted InformationReport evidence.";
+ return false;
+ }
+
+ var normalizedIncluded = evidence.IncludedMemberReferences
+ .Select(MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.NormalizeMms)
+ .ToArray();
+ if (normalizedIncluded.Any(string.IsNullOrWhiteSpace) ||
+ normalizedIncluded.Distinct(StringComparer.OrdinalIgnoreCase).Count() != normalizedIncluded.Length)
+ {
+ reason = "Native field-capability included-member evidence is empty or duplicated.";
+ return false;
+ }
+
+ if (report.ReportAuthoritativePointCount != evidence.IncludedMemberReferences.Count)
+ {
+ reason = $"Native field-capability included-member count {evidence.IncludedMemberReferences.Count} does not match persisted authoritative report count {report.ReportAuthoritativePointCount}.";
+ return false;
+ }
+
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.IsOrderedMemberSubset(
+ evidence.IncludedMemberReferences,
+ report.MemberReferences))
+ {
+ reason = "Native field-capability included members are not an ordered subset of the persisted DataChange report DataSet.";
+ return false;
+ }
+
+ if (!MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.IsOrderedMemberSubset(
+ evidence.IncludedMemberReferences,
+ envelope.ExactProvenMemberReferences))
+ {
+ reason = "Native field-capability included members are outside the accepted qualification envelope.";
+ return false;
+ }
+
+ reason =
+ "P1.7 native field-capability witness accepted: exact identity/profile/activation/report/RCB/DataSet binding, actual NO-GI dchg mapping, association health, monitor cleanup, proof-field restore and fresh cleanup closure all match. The witness proves the dynamic reporting mechanism; fresh exact RCB availability and live member resolution still govern every runtime DataSet. 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);
+}
+
+///
+/// P1.7 general Dynamic RCB runtime for a natively commissioned per-IED field-capability
+/// witness. Static reporting keeps precedence. Every still-uncovered exact-resolved signal
+/// may use bounded fresh verified-free dynamic RCB slots; only genuine residuals poll.
+///
+public static class MmsGuardedDynamicReportNativeFieldCapabilityRuntimePlanner
+{
+ public static MmsCapabilityAwareHybridReportAcquisitionPlan Build(
+ Iec61850SignalCatalogDocument catalog,
+ IEnumerable requestedSignals,
+ MmsReportInventory inventory,
+ MmsRcbAvailabilityResult availability,
+ MmsIedModelDirectory liveDirectory,
+ AcseMmsNegotiatedCapabilities? negotiatedCapabilities,
+ MmsHybridReportAcquisitionOptions? options,
+ MmsDynamicReportGuardedRuntimePlanningContext sourceContext,
+ MmsDynamicReportNativeFieldCapabilityEvidence 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 materializedSignals = requestedSignals.ToArray();
+ var capability = MmsReportAssociationCapabilityEvaluator.Evaluate(
+ availability,
+ negotiatedCapabilities,
+ options);
+ var dynamicIntent = options.AllowDynamicBrcb || options.AllowDynamicUrcb;
+ var authorizationReason = string.Empty;
+
+ var authorized = dynamicIntent &&
+ capability.MayAttemptDynamicReports &&
+ MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate(
+ sourceContext,
+ evidence,
+ out authorizationReason);
+
+ if (!dynamicIntent)
+ authorizationReason = "Dynamic BRCB/URCB acquisition is disabled by planner policy.";
+ else if (!capability.MayAttemptDynamicReports)
+ authorizationReason = "The current MMS association does not satisfy the dynamic-report capability gate.";
+
+ var effectiveOptions = BuildOptions(options, authorized);
+ var plan = MmsHybridReportAcquisitionPlanner.Build(
+ catalog,
+ materializedSignals,
+ inventory,
+ availability,
+ liveDirectory,
+ effectiveOptions);
+
+ var policyWarnings = new List();
+ if (dynamicIntent && capability.MayAttemptDynamicReports && !authorized)
+ {
+ policyWarnings.Add(
+ $"P1.7 native field-capability general dynamic reporting remains withheld: {authorizationReason}");
+ }
+
+ if (authorized && !ValidateDynamicSegments(plan, availability, liveDirectory, effectiveOptions, out var invariantFailure))
+ {
+ policyWarnings.Add(
+ $"P1.7 native general dynamic plan failed fresh runtime invariants and was withheld: {invariantFailure}");
+ authorized = false;
+ authorizationReason = invariantFailure;
+
+ plan = MmsHybridReportAcquisitionPlanner.Build(
+ catalog,
+ materializedSignals,
+ inventory,
+ availability,
+ liveDirectory,
+ BuildOptions(options, false));
+ }
+
+ RestoreFreshAttributeEvidence(plan, availability, authorized);
+
+ return new MmsCapabilityAwareHybridReportAcquisitionPlan
+ {
+ AcquisitionPlan = plan,
+ AssociationCapability = capability,
+ AutomaticDynamicActivationQuarantined =
+ capability.MayAttemptDynamicReports && dynamicIntent && !authorized,
+ ProductionDynamicActivationAuthorized = false,
+ ProductionDynamicAuthorizationReason = authorized
+ ? "P1.7 native per-IED field-capability normal runtime is active for fresh exact-resolved residual signals and fresh verified-free dynamic RCBs; ProductionEligible certification remains separate."
+ : authorizationReason,
+ ProductionQualifiedDynamicMemberCount = 0,
+ ProductionQualifiedRcbReference = string.Empty,
+ PolicyWarnings = policyWarnings.ToArray()
+ };
+ }
+
+ private static MmsHybridReportAcquisitionOptions BuildOptions(
+ MmsHybridReportAcquisitionOptions source,
+ bool authorized)
+ => new()
+ {
+ MaxStaticReportPlans = source.MaxStaticReportPlans,
+ MaxDynamicReportPlans = source.MaxDynamicReportPlans,
+ MaxDynamicMembersPerReport = 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,
+ MmsRcbAvailabilityResult availability,
+ MmsIedModelDirectory liveDirectory,
+ MmsHybridReportAcquisitionOptions options,
+ out string reason)
+ {
+ var dynamicSegments = plan.Segments
+ .Where(segment => segment.Kind is MmsHybridAcquisitionKind.DynamicBrcb or MmsHybridAcquisitionKind.DynamicUrcb)
+ .ToArray();
+
+ if (dynamicSegments.Length == 0)
+ {
+ reason = "No dynamic segment was needed or safely available for this request.";
+ return true;
+ }
+
+ if (dynamicSegments.Length > options.MaxDynamicReportPlans)
+ {
+ reason = $"Planner emitted {dynamicSegments.Length} dynamic groups above configured limit {options.MaxDynamicReportPlans}.";
+ return false;
+ }
+
+ var distinctRcbCount = dynamicSegments
+ .Select(segment => NormalizeRcb(segment.ReportControlReference))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Count();
+ if (distinctRcbCount != dynamicSegments.Length)
+ {
+ reason = "Planner reused one dynamic RCB for more than one runtime group.";
+ return false;
+ }
+
+ var exactLiveMembers = liveDirectory.Points
+ .Where(point => point.Confidence > 0)
+ .Select(point => NormalizeMms(point.MmsReference))
+ .Where(reference => !string.IsNullOrWhiteSpace(reference))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var segment in dynamicSegments)
+ {
+ var snapshots = availability.ReportControls
+ .Where(snapshot => SameRcb(snapshot.Reference, segment.ReportControlReference))
+ .ToArray();
+ if (snapshots.Length != 1)
+ {
+ reason = $"Dynamic RCB {segment.ReportControlReference} does not have exactly one fresh availability snapshot.";
+ return false;
+ }
+
+ var snapshot = snapshots[0];
+ if (!IsFreshDynamicSlot(snapshot, segment.Kind, options))
+ {
+ reason = $"Dynamic RCB {segment.ReportControlReference} is not a fresh exact verified-free empty slot.";
+ return false;
+ }
+
+ if (segment.ReportPlan is null || segment.ReportPlan.DynamicPoints.Count == 0)
+ {
+ reason = $"Dynamic RCB {segment.ReportControlReference} has no exact resolved members.";
+ return false;
+ }
+
+ if (segment.ReportPlan.DynamicPoints.Count > options.MaxDynamicMembersPerReport)
+ {
+ reason = $"Dynamic RCB {segment.ReportControlReference} has {segment.ReportPlan.DynamicPoints.Count} members above configured limit {options.MaxDynamicMembersPerReport}.";
+ return false;
+ }
+
+ foreach (var point in segment.ReportPlan.DynamicPoints)
+ {
+ var member = NormalizeMms(point.MmsReference);
+ if (string.IsNullOrWhiteSpace(member) || !exactLiveMembers.Contains(member))
+ {
+ reason = $"Dynamic member {point.MmsReference} is not present in the current exact live MMS directory.";
+ return false;
+ }
+ }
+ }
+
+ reason = $"P1.7 fresh runtime invariants accepted for {dynamicSegments.Length} dynamic RCB group(s).";
+ return true;
+ }
+
+ private static bool IsFreshDynamicSlot(
+ MmsRcbAvailabilitySnapshot snapshot,
+ MmsHybridAcquisitionKind kind,
+ MmsHybridReportAcquisitionOptions options)
+ {
+ var kindAllowed = kind switch
+ {
+ MmsHybridAcquisitionKind.DynamicBrcb => options.AllowDynamicBrcb && snapshot.Buffered,
+ MmsHybridAcquisitionKind.DynamicUrcb => options.AllowDynamicUrcb && !snapshot.Buffered,
+ _ => false
+ };
+ if (!kindAllowed)
+ return false;
+
+ if (snapshot.Availability != MmsRcbOperationalAvailability.NoDataSet ||
+ snapshot.Confidence != MmsRcbAvailabilityConfidence.Exact ||
+ snapshot.DataSetProbeState != MmsRcbDataSetProbeState.ReadSucceeded ||
+ !string.IsNullOrWhiteSpace(snapshot.DataSetReference) ||
+ ParseBoolean(snapshot.EnabledState) == true)
+ {
+ return false;
+ }
+
+ if (snapshot.Buffered)
+ {
+ var reservation = ParseInt(snapshot.ReservationTimeSeconds);
+ return reservation is null or 0;
+ }
+
+ return ParseBoolean(snapshot.ReservationState) != true;
+ }
+
+ private static void RestoreFreshAttributeEvidence(
+ MmsHybridReportAcquisitionPlan plan,
+ MmsRcbAvailabilityResult availability,
+ bool fieldCapabilityAuthorized)
+ {
+ 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 => SameRcb(item.Reference, candidate.Reference));
+ 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
+ ? "P1.7 baseline-static fresh availability snapshot"
+ : fieldCapabilityAuthorized
+ ? "G2.7 native field-capability general-dynamic fresh availability snapshot"
+ : "G2.7 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 SameRcb(string? left, string? right)
+ => string.Equals(NormalizeRcb(left), NormalizeRcb(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeRcb(string? reference)
+ => MmsRcbAvailabilityEvaluator.NormalizeReference(reference).Replace('\\', '/');
+
+ private static string NormalizeMms(string? reference)
+ => MmsFcReferenceNormalizer.NormalizeMmsReference(reference ?? string.Empty);
+
+ private static bool? ParseBoolean(string? value)
+ {
+ if (bool.TryParse(value, out var parsed))
+ return parsed;
+ if (int.TryParse(value, out var numeric))
+ return numeric != 0;
+ return null;
+ }
+
+ private static int? ParseInt(string? value)
+ => int.TryParse(value, out var parsed) ? parsed : null;
+}
+
+///
+/// Stable per-RCB DataSet identity wrapper for P1.7 native field capability.
+///
+public static class MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner
+{
+ public static MmsCapabilityAwareHybridReportAcquisitionPlan Build(
+ Iec61850SignalCatalogDocument catalog,
+ IEnumerable requestedSignals,
+ MmsReportInventory inventory,
+ MmsRcbAvailabilityResult availability,
+ MmsIedModelDirectory liveDirectory,
+ AcseMmsNegotiatedCapabilities? negotiatedCapabilities,
+ MmsHybridReportAcquisitionOptions? options,
+ MmsDynamicReportGuardedRuntimePlanningContext sourceContext,
+ MmsDynamicReportNativeFieldCapabilityEvidence evidence)
+ {
+ var plan = MmsGuardedDynamicReportNativeFieldCapabilityRuntimePlanner.Build(
+ catalog,
+ requestedSignals,
+ inventory,
+ availability,
+ liveDirectory,
+ negotiatedCapabilities,
+ options,
+ sourceContext,
+ evidence);
+
+ return MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner
+ .WithStableDynamicDataSetIdentities(plan);
+ }
+}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG27NativeFieldCapabilityRuntimeTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG27NativeFieldCapabilityRuntimeTests.cs
new file mode 100644
index 00000000..85d861cd
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsG27NativeFieldCapabilityRuntimeTests.cs
@@ -0,0 +1,331 @@
+using AR.Iec61850.Acse;
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsG27NativeFieldCapabilityRuntimeTests
+{
+ [Fact]
+ public void NativeDataChangeWitness_WithCompleteCleanup_UnlocksGeneralDynamicCoverage()
+ {
+ var signals = Enumerable.Range(1, 3).Select(Signal).ToArray();
+ var plan = BuildPlan(signals, NativeEvidence(), maxMembers: 2);
+
+ Assert.False(plan.AutomaticDynamicActivationQuarantined);
+ Assert.False(plan.ProductionDynamicActivationAuthorized);
+ Assert.Equal(3, plan.AcquisitionPlan.DynamicUrcbSignalCount);
+ Assert.Equal(0, plan.AcquisitionPlan.PollingFallbackSignalCount);
+
+ var dynamicSegments = plan.AcquisitionPlan.Segments
+ .Where(segment => segment.Kind == MmsHybridAcquisitionKind.DynamicUrcb)
+ .ToArray();
+ Assert.Equal(2, dynamicSegments.Length);
+ Assert.Contains(dynamicSegments, segment => segment.ReportPlan!.DynamicPoints.Any(point =>
+ point.MmsReference.Equals(Member(3), StringComparison.OrdinalIgnoreCase)));
+
+ var dataSets = dynamicSegments
+ .Select(segment => segment.DataSetReference)
+ .ToArray();
+ Assert.All(dataSets, reference => Assert.Contains("AR_HYB_", reference, StringComparison.OrdinalIgnoreCase));
+ Assert.Equal(dataSets.Length, dataSets.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ Assert.Contains("native per-IED field-capability", plan.ProductionDynamicAuthorizationReason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("ProductionEligible", plan.ProductionDynamicAuthorizationReason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void NativeDataChangeWitness_MissingFreshCleanupClosure_FailsClosedToPolling()
+ {
+ var signals = Enumerable.Range(1, 3).Select(Signal).ToArray();
+ var evidence = NativeEvidence() with { FreshCleanupClosureSucceeded = false };
+ var plan = BuildPlan(signals, evidence, maxMembers: 2);
+
+ Assert.True(plan.AutomaticDynamicActivationQuarantined);
+ Assert.Equal(0, plan.AcquisitionPlan.DynamicUrcbSignalCount);
+ Assert.Equal(3, plan.AcquisitionPlan.PollingFallbackSignalCount);
+ Assert.Contains("complete native physical", plan.ProductionDynamicAuthorizationReason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void NativeDataChangeWitness_FromDifferentDataSet_FailsClosed()
+ {
+ var context = new MmsDynamicReportGuardedRuntimePlanningContext
+ {
+ Profile = NativeProfile(),
+ CurrentIdentity = Identity()
+ };
+ var evidence = NativeEvidence() with { DataSetReference = "LD0/LLN0.AR_OTHER" };
+
+ var accepted = MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate(
+ context,
+ evidence,
+ out var reason);
+
+ Assert.False(accepted);
+ Assert.Contains("DataSet", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void NativeDataChangeProfile_WithoutSeparateCleanupWitness_IsNotEnough()
+ {
+ var context = new MmsDynamicReportGuardedRuntimePlanningContext
+ {
+ Profile = NativeProfile(),
+ CurrentIdentity = Identity()
+ };
+
+ var accepted = MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate(
+ context,
+ null,
+ out var reason);
+
+ Assert.False(accepted);
+ Assert.Contains("dchg + cleanup", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static MmsCapabilityAwareHybridReportAcquisitionPlan BuildPlan(
+ IReadOnlyCollection signals,
+ MmsDynamicReportNativeFieldCapabilityEvidence evidence,
+ int maxMembers)
+ {
+ var catalog = new Iec61850SignalCatalogDocument
+ {
+ IedName = "G27_NATIVE_IED",
+ Source = "P1.7 native field-capability fixture",
+ Signals = signals.ToArray()
+ };
+
+ return MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner.Build(
+ catalog,
+ signals,
+ DynamicInventory(1, 2),
+ DynamicAvailability(1, 2),
+ Directory(1, 2, 3),
+ new AcseMmsNegotiatedCapabilities
+ {
+ IsDecoded = true,
+ SupportsWrite = true,
+ SupportsDefineNamedVariableList = true,
+ SupportsDeleteNamedVariableList = true
+ },
+ new MmsHybridReportAcquisitionOptions
+ {
+ MaxDynamicReportPlans = 4,
+ MaxDynamicMembersPerReport = maxMembers,
+ AllowStaticBrcb = false,
+ AllowStaticUrcb = false,
+ AllowDynamicBrcb = false,
+ AllowDynamicUrcb = true,
+ AllowCallerOwnedReports = false,
+ AllowPollingFallback = true,
+ RequireExactAvailabilityEvidence = true
+ },
+ new MmsDynamicReportGuardedRuntimePlanningContext
+ {
+ Profile = NativeProfile(),
+ CurrentIdentity = Identity()
+ },
+ evidence);
+ }
+
+ private static MmsDynamicReportNativeFieldCapabilityEvidence NativeEvidence()
+ => new()
+ {
+ EvidenceId = "g27-native-capability-witness",
+ ObservedAtUtc = Time(6),
+ StableIdentityKey = Identity().StableIdentityKey,
+ ModelFingerprint = Identity().ModelFingerprint,
+ ProfileRevision = Identity().ProfileRevision,
+ RcbReference = DynamicRcb(1),
+ DataSetReference = ProvenDataSetReference,
+ RcbActivationEvidenceId = ActivationEvidenceId,
+ InformationReportEvidenceId = ReportEvidenceId,
+ IncludedMemberReferences = [Member(1)],
+ ActualInformationReportReceived = true,
+ DataChangeReasonVerified = true,
+ GeneralInterrogationDisabled = true,
+ ExactMemberMappingVerified = true,
+ AssociationHealthyAfterReport = true,
+ MonitorCleanupSucceeded = true,
+ ProofFieldRestoreSucceeded = true,
+ FreshCleanupClosureSucceeded = true
+ };
+
+ private static MmsDynamicReportQualificationProfile NativeProfile()
+ {
+ var members = new[] { Member(1), Member(2) };
+ var assessment = MmsDynamicDataSetQualificationLadder.Assess(
+ [
+ new MmsDynamicDataSetQualificationAttemptEvidence
+ {
+ AttemptId = "g27-envelope",
+ ObservedAtUtc = Time(1),
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ DefineRequestByteCount = 220,
+ NegotiatedMaxMmsPduSize = 65000,
+ RequestWithinKnownNegotiatedPdu = true,
+ IsSuccess = true,
+ FailureStage = MmsDynamicDataSetQualificationFailureStage.None,
+ DynamicMutationAttempted = true,
+ AssociationSurvived = true,
+ CleanupSucceeded = true
+ }
+ ]);
+ var envelope = MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope(assessment, "g27-envelope");
+ var profile = MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile(
+ Identity(),
+ envelope,
+ assessment,
+ new MmsDynamicReportCapacityEvidence
+ {
+ ObservedFreeBrcbSlots = 0,
+ ObservedFreeUrcbSlots = 2,
+ ObservedAtUtc = Time(2),
+ EvidenceId = "g27-capacity"
+ },
+ "g27-envelope-evidence",
+ Time(3));
+
+ var activated = MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof(
+ profile,
+ Identity(),
+ new MmsDynamicRcbActivationProof
+ {
+ EvidenceId = ActivationEvidenceId,
+ ObservedAtUtc = Time(4),
+ RcbReference = DynamicRcb(1),
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ FreshRcbAvailabilityVerified = true,
+ DataSetReadbackVerified = true,
+ RcbDataSetBindingAccepted = true,
+ RptEnaAccepted = true,
+ AssociationHealthyAfterActivation = true
+ });
+
+ return MmsDynamicReportQualificationProfilePolicy.RecordInformationReportProof(
+ activated,
+ Identity(),
+ new MmsDynamicInformationReportProof
+ {
+ EvidenceId = ReportEvidenceId,
+ ObservedAtUtc = Time(5),
+ RcbReference = DynamicRcb(1),
+ DataSetReference = ProvenDataSetReference,
+ MemberReferences = members,
+ Kind = MmsDynamicInformationReportKind.DataChange,
+ ActualInformationReportReceived = true,
+ ReportIdentityVerified = true,
+ ExactMemberMappingVerified = true,
+ AssociationHealthyAfterReport = true,
+ ReportAuthoritativePointCount = 1
+ });
+ }
+
+ private static MmsDynamicReportIedIdentity Identity()
+ => new()
+ {
+ StableIdentityKey = "ied:g27:native",
+ ModelFingerprint = "sha256:g27-native-model",
+ Manufacturer = "Example",
+ Model = "NativeIED",
+ FirmwareRevision = "1.0.0",
+ ProfileRevision = "cfg-g27"
+ };
+
+ private static MmsReportInventory DynamicInventory(params int[] indexes)
+ {
+ var inventory = new MmsReportInventory();
+ foreach (var index in indexes)
+ {
+ inventory.ReportControls.Add(new MmsReportControlCandidate
+ {
+ Domain = "LD0",
+ LogicalNode = "LLN0",
+ FunctionalConstraint = "RP",
+ Name = $"Unbuffer{index:00}",
+ Reference = DynamicRcb(index),
+ Buffered = false,
+ DataSetReference = string.Empty,
+ DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded,
+ EnabledState = "false",
+ ReservationState = "false",
+ TriggerOptions = "dchg",
+ ReportId = $"Unbuffer{index:00}",
+ ConfRev = "1"
+ });
+ }
+
+ return inventory;
+ }
+
+ private static MmsRcbAvailabilityResult DynamicAvailability(params int[] indexes)
+ => new()
+ {
+ CheckedAtUtc = Time(10),
+ ReportControls = indexes.Select(DynamicSnapshot).ToArray()
+ };
+
+ private static MmsRcbAvailabilitySnapshot DynamicSnapshot(int index)
+ => new()
+ {
+ Reference = DynamicRcb(index),
+ Domain = "LD0",
+ LogicalNode = "LLN0",
+ Name = $"Unbuffer{index:00}",
+ Mode = "URCB",
+ Buffered = false,
+ DataSetReference = string.Empty,
+ DataSetProbeState = MmsRcbDataSetProbeState.ReadSucceeded,
+ ReportId = $"Unbuffer{index:00}",
+ 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.7 exact empty URCB fixture",
+ Attributes = ["DatSet", "RptEna", "TrgOps", "OptFlds", "Resv"]
+ };
+
+ private static MmsIedModelDirectory Directory(params int[] indexes)
+ => new(indexes.Select(index => new MmsFcResolvedPoint
+ {
+ Domain = "LD0",
+ LogicalNode = "GGIO1",
+ FunctionalConstraint = "ST",
+ DataObjectPath = $"Ind{index}.stVal",
+ MmsItemName = $"GGIO1$ST$Ind{index}$stVal",
+ Source = "P1.7 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 DynamicRcb(int index) => $"LD0/LLN0.RP.Unbuffer{index:00}";
+ private static string Member(int index) => $"LD0/GGIO1$ST$Ind{index}$stVal";
+ private static DateTimeOffset Time(int minutes) => DateTimeOffset.Parse("2026-09-01T00:00:00Z").AddMinutes(minutes);
+
+ private const string ProvenDataSetReference = "LD0/LLN0.AR_G27_BOOT";
+ private const string ActivationEvidenceId = "g27-native-activation";
+ private const string ReportEvidenceId = "g27-native-dchg-report";
+}