diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec3c9cb..fed5083 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ All notable public changes to ARIEC61850 are recorded here. The project is still
### Added
- Added a typed hybrid report acquisition planner that can cover requested signals with safe static BRCB/URCB plans, use explicitly free dynamic report slots for residual signals, and leave only the remaining points on MMS polling fallback without equating fallback with absence.
+- Added the G2.6 production dynamic-report consumer: an optional typed production-planning context can authorize automatic dynamic planning only from an identity-compatible `ProductionEligible` profile, with exact proven InformationReport RCB/member scope and fail-closed fallback for unproven points.
- Added typed IEC 61850 UTC-Time forensic evidence with exact preserved 8-byte wire provenance, the original 24-bit fractional-second field, TimeQuality/accuracy decoding, and explicit full/engineering UTC and local-offset views.
- Added report timestamp evidence that keeps IED data timestamps, report `TimeOfEntry`, and client `ReceivedAt` as distinct evidence sources; raw `TimeOfEntry` provenance is linked only when an exact decoded-report match exists.
@@ -14,6 +15,7 @@ All notable public changes to ARIEC61850 are recorded here. The project is still
- Native MMS discovery can now carry bounded, ordered DataSet-directory results in its typed result, allowing live-model, engineering-profile, and readiness builders to consume the same member evidence without application-side reconstruction.
- Hybrid dynamic reporting can host a temporary DataSet in a verified-free RCB Logical Device while its members reference live points in other Logical Devices, matching the MMS named-variable-list model used by Siemens auxiliary RCB pools.
+- Production automatic dynamic planning remains P6.2-B quarantined for callers without a valid production context; even when authorized, the first G2.6 consumer is limited to the exact report-proven RCB, report-proven members, one dynamic group, and fresh live availability.
- Preserved full seven-digit UTC-Time precision through the shared MMS scalar/structured renderer and retained exact decoded UTC-Time bytes for byte-exact re-encoding; synthetic UTC-Time values remain explicitly without wire provenance.
- Corrected website structured-data licensing to `GPL-3.0-or-later`.
- Replaced stale active-license wording and milestone journals with current evidence and future-only roadmap documents.
@@ -61,4 +63,4 @@ All notable public changes to ARIEC61850 are recorded here. The project is still
Earlier milestones include MMS association and discovery, reporting readiness and persistent monitoring, GOOSE and Sampled Values codecs and diagnostics, SCL engineering profiles, PCAP workflows, deterministic simulation, Windows raw-Ethernet transport, Sampled Values laboratory publishing, and engineering evidence export.
-See [Engine Maturity Matrix](docs/ENGINE_MATURITY_MATRIX.md) for current evidence and [Roadmap](ROADMAP.md) for future work.
+See [Engine Maturity Matrix](docs/ENGINE_MATURITY_MATRIX.md) for current evidence and [Roadmap](ROADMAP.md) for future work.
\ No newline at end of file
diff --git a/docs/G2_6_PRODUCTION_DYNAMIC_CONSUMER.md b/docs/G2_6_PRODUCTION_DYNAMIC_CONSUMER.md
new file mode 100644
index 0000000..60cc6c2
--- /dev/null
+++ b/docs/G2_6_PRODUCTION_DYNAMIC_CONSUMER.md
@@ -0,0 +1,86 @@
+# G2.6 Production Dynamic Reporting Consumer
+
+## Status
+
+Implemented and unit-tested on the `g2.6-production-dynamic-consumer` branch. This document describes the production-planning contract only; it is not a claim of new live-IED interoperability.
+
+## Purpose
+
+P6.2-B deliberately quarantines automatic full dynamic DataSet activation because advertised MMS capability and a successful single-member NamedVariableList probe were not sufficient evidence for safe production mutation.
+
+G2.6 adds the missing production consumer for the existing persisted qualification profile. The capability-aware hybrid planner can now consider dynamic reporting only when an application supplies a `MmsDynamicReportProductionPlanningContext` containing the persisted profile and the current IED identity.
+
+## Authorization gate
+
+Dynamic production planning requires all of the following:
+
+1. dynamic BRCB or URCB intent is enabled in planner options;
+2. the current association passes the existing dynamic-report capability evaluator;
+3. `MmsDynamicReportQualificationProfilePolicy.CanUseForProductionPlanning(...)` accepts the profile;
+4. the profile is identity-compatible and exactly `ProductionEligible`;
+5. the activation proof and InformationReport proof agree on RCB, DataSet, and exact member sequence;
+6. the InformationReport member set is an ordered subset of the accepted qualified envelope;
+7. the production member evidence is non-empty and contains no duplicate normalized MMS references.
+
+A profile at `InformationReportProven` or any earlier state remains quarantined.
+
+## First production-consumer scope
+
+The initial consumer is intentionally narrower than the theoretical qualified envelope:
+
+- static BRCB/URCB coverage is always planned first;
+- automatic dynamic planning is restricted to the exact RCB that produced the proven InformationReport;
+- only exact members from that proven InformationReport are exposed to the production dynamic planner;
+- automatic production dynamic scale-out is limited to one dynamic group;
+- the per-report member ceiling is clamped to the proven InformationReport member count;
+- unproven or unrelated requested members remain on bounded MMS polling;
+- another free RCB cannot silently substitute for the proven RCB.
+
+This deliberately avoids generalizing a successful NVL envelope into report authority for members or RCBs that have not produced the proven report.
+
+## Fresh live evidence remains mandatory
+
+`ProductionEligible` is permission to consider the dynamic path, not permission to perform a blind write. Normal live planning still requires exact fresh RCB availability. If the proven RCB is unavailable, occupied, not explicitly free, or otherwise fails the existing dynamic-slot rules, the planner emits no dynamic segment and leaves the affected signals on polling.
+
+## Post-plan invariant
+
+Persisted profile material is treated as untrusted input. After the generic hybrid planner returns, the capability-aware wrapper verifies that any dynamic segment:
+
+- uses no more than one dynamic RCB;
+- uses the exact proven RCB;
+- contains resolved dynamic points;
+- contains only an ordered subset of the exact proven InformationReport member set.
+
+If this invariant fails, the dynamic plan is discarded and planning is rebuilt with the frozen static-to-polling behavior.
+
+## Compatibility
+
+The existing `MmsCapabilityAwareHybridReportAcquisitionPlanner.Build(...)` call remains source-compatible. The production context is an optional final argument. Existing callers that do not provide it retain P6.2-B quarantine behavior.
+
+## Deterministic validation
+
+`MmsG26ProductionDynamicConsumerTests` covers:
+
+- `InformationReportProven` remains quarantined;
+- an identity-compatible `ProductionEligible` profile can authorize the exact proven URCB/member set;
+- an unproven requested member remains on polling;
+- identity/fingerprint mismatch fails closed;
+- a different free RCB cannot substitute for the proven RCB;
+- tampered persisted member evidence is rejected.
+
+CI command set:
+
+```powershell
+dotnet restore .\ARIEC61850.sln
+dotnet build .\ARIEC61850.sln -c Release --no-restore
+dotnet test .\ARIEC61850.sln -c Release --no-build
+.\scripts\verify-source-clean.ps1
+```
+
+The first successful branch validation ran 703 tests with 703 passed, zero build warnings, and zero build errors.
+
+## What remains unproven
+
+This engine patch does not itself make any IED `ProductionEligible` and does not establish live field behavior for a new device. Applications must still complete the physical G2.6 acceptance gates before persisting `ProductionEligible`, then supply that profile and the matching current IED identity to this consumer.
+
+The next lowest-risk application step is to wire the typed production context into ARSAS while keeping current non-qualified IEDs on the existing static-report/polling behavior.
\ No newline at end of file
diff --git a/src/AR.Iec61850/Mms/MmsCapabilityAwareHybridReportAcquisitionPlanner.cs b/src/AR.Iec61850/Mms/MmsCapabilityAwareHybridReportAcquisitionPlanner.cs
index dcfa933..c908227 100644
--- a/src/AR.Iec61850/Mms/MmsCapabilityAwareHybridReportAcquisitionPlanner.cs
+++ b/src/AR.Iec61850/Mms/MmsCapabilityAwareHybridReportAcquisitionPlanner.cs
@@ -3,6 +3,18 @@
namespace AR.Iec61850.Mms;
+///
+/// Explicit production-planning evidence supplied by an application after it has loaded the
+/// persisted qualification profile for the currently connected IED. Supplying this object is
+/// not itself permission for a dynamic write; the engine revalidates the complete profile and
+/// current identity through MmsDynamicReportQualificationProfilePolicy.
+///
+public sealed record MmsDynamicReportProductionPlanningContext
+{
+ public MmsDynamicReportQualificationProfile Profile { get; init; } = new();
+ public MmsDynamicReportIedIdentity CurrentIdentity { get; init; } = new();
+}
+
///
/// Capability-aware orchestration around the stable static -> dynamic -> polling planner.
/// Static RCB coverage keeps the stable planner's original fresh-availability semantics;
@@ -13,12 +25,22 @@ public sealed class MmsCapabilityAwareHybridReportAcquisitionPlan
public MmsHybridReportAcquisitionPlan AcquisitionPlan { get; init; } = new();
public MmsReportAssociationCapability AssociationCapability { get; init; } = new();
public bool AutomaticDynamicActivationQuarantined { get; init; }
+ public bool ProductionDynamicActivationAuthorized { get; init; }
+ public string ProductionDynamicAuthorizationReason { get; init; } = string.Empty;
+ public int ProductionQualifiedDynamicMemberCount { get; init; }
+ public string ProductionQualifiedRcbReference { get; init; } = string.Empty;
+ public IReadOnlyList PolicyWarnings { get; init; } = Array.Empty();
+
+ public string Summary =>
+ $"{AcquisitionPlan.Summary} {AssociationCapability.Summary} " +
+ $"ProductionDynamic={(ProductionDynamicActivationAuthorized ? "authorized" : "not-authorized")}.";
- public string Summary => $"{AcquisitionPlan.Summary} {AssociationCapability.Summary}";
public IReadOnlyList Warnings => AcquisitionPlan.Warnings
.Concat(AssociationCapability.Warnings)
+ .Concat(PolicyWarnings)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
+
public IReadOnlyList Blockers => AcquisitionPlan.Blockers;
}
@@ -31,7 +53,8 @@ public static MmsCapabilityAwareHybridReportAcquisitionPlan Build(
MmsRcbAvailabilityResult availability,
MmsIedModelDirectory liveDirectory,
AcseMmsNegotiatedCapabilities? negotiatedCapabilities = null,
- MmsHybridReportAcquisitionOptions? options = null)
+ MmsHybridReportAcquisitionOptions? options = null,
+ MmsDynamicReportProductionPlanningContext? productionContext = null)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(requestedSignals);
@@ -45,51 +68,86 @@ public static MmsCapabilityAwareHybridReportAcquisitionPlan Build(
negotiatedCapabilities,
options);
+ var dynamicIntent = options.AllowDynamicBrcb || options.AllowDynamicUrcb;
+ var production = EvaluateProductionAuthorization(
+ productionContext,
+ capability,
+ dynamicIntent);
+
// P6.1 stability rule:
// Do not place the P3 capability wrapper in front of the stable static planner.
// A populated DataSet discovered with an exact fresh directory is static protocol
// evidence and must remain visible to MmsHybridReportAcquisitionPlanner, which owns
// the established static-usability checks.
//
- // P6.2-B field-stability rule:
- // The single-member Define/GetAttributes/Delete probation proved that an association
- // can support the NamedVariableList service while a later full dynamic DataSet still
- // aborts that same association. Therefore advertised capability or a successful
- // one-member probation is not sufficient permission for automatic full dynamic
- // activation on the production monitoring association. Keep the dynamic primitive
- // available for explicit diagnostics, but automatic acquisition is static -> polling
- // until staged multi-member interoperability has been proven independently.
+ // P6.2-B / G2.6 production rule:
+ // Advertised MMS capability or an earlier qualification stage is never enough for
+ // automatic full dynamic activation. A valid, identity-compatible ProductionEligible
+ // profile is required. Even then, this first production consumer deliberately scopes
+ // automatic dynamic planning to:
+ // - the exact RCB that produced the proven InformationReport;
+ // - only the exact members carried by that proven InformationReport;
+ // - at most one dynamic group and never beyond the accepted envelope size.
+ // Everything outside that evidence remains static-report-backed or MMS polling.
var configuredStaticReferences = availability.ReportControls
.Where(HasConfiguredStaticDataSetEvidence)
.Select(snapshot => Normalize(snapshot.Reference))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- var restrictedAvailability = new MmsRcbAvailabilityResult
- {
- CheckedAtUtc = availability.CheckedAtUtc,
- ReportControls = availability.ReportControls
- .Where(snapshot => configuredStaticReferences.Contains(Normalize(snapshot.Reference)))
- .ToArray(),
- Warnings = availability.Warnings
- .Concat(capability.Warnings)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToArray()
- };
+ var allowedRcbReferences = configuredStaticReferences.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (production.IsAuthorized)
+ allowedRcbReferences.Add(Normalize(production.RcbReference));
- var restrictedInventory = new MmsReportInventory();
- restrictedInventory.DataSets.AddRange(inventory.DataSets);
- restrictedInventory.ReportControls.AddRange(inventory.ReportControls
- .Where(candidate => configuredStaticReferences.Contains(Normalize(candidate.Reference))));
+ var restrictedAvailability = RestrictAvailability(
+ availability,
+ allowedRcbReferences,
+ capability.Warnings);
+ var restrictedInventory = RestrictInventory(inventory, allowedRcbReferences);
+ var planningDirectory = production.IsAuthorized
+ ? RestrictDirectoryToQualifiedMembers(liveDirectory, production.MemberReferences)
+ : liveDirectory;
- var automaticOptions = AutomaticMonitoringOptions(options);
+ var automaticOptions = AutomaticMonitoringOptions(options, production);
var plan = MmsHybridReportAcquisitionPlanner.Build(
catalog,
requestedSignals,
restrictedInventory,
restrictedAvailability,
- liveDirectory,
+ planningDirectory,
automaticOptions);
+ var policyWarnings = new List();
+ if (dynamicIntent && capability.MayAttemptDynamicReports && !production.IsAuthorized)
+ {
+ policyWarnings.Add(
+ $"Automatic dynamic reporting remains quarantined: {production.Reason}");
+ }
+
+ // Treat persisted profiles as untrusted input. CanUseForProductionPlanning validates
+ // the state/evidence chain, and this post-plan check additionally proves that the
+ // generic hybrid planner did not emit a dynamic segment outside the exact production
+ // evidence scope. Any mismatch drops back to the frozen static -> polling behavior.
+ if (production.IsAuthorized &&
+ !ValidateProductionDynamicSegments(plan, production, out var invariantFailure))
+ {
+ policyWarnings.Add(
+ $"Production dynamic plan failed the exact evidence-scope invariant and was quarantined: {invariantFailure}");
+ production = ProductionDynamicAuthorization.Denied(invariantFailure);
+
+ var staticAvailability = RestrictAvailability(
+ availability,
+ configuredStaticReferences,
+ capability.Warnings);
+ var staticInventory = RestrictInventory(inventory, configuredStaticReferences);
+ plan = MmsHybridReportAcquisitionPlanner.Build(
+ catalog,
+ requestedSignals,
+ staticInventory,
+ staticAvailability,
+ liveDirectory,
+ AutomaticMonitoringOptions(options, production));
+ }
+
RestoreFreshAttributeEvidence(plan, availability);
return new MmsCapabilityAwareHybridReportAcquisitionPlan
@@ -98,29 +156,177 @@ public static MmsCapabilityAwareHybridReportAcquisitionPlan Build(
AssociationCapability = capability,
AutomaticDynamicActivationQuarantined =
capability.MayAttemptDynamicReports &&
- (options.AllowDynamicBrcb || options.AllowDynamicUrcb)
+ dynamicIntent &&
+ !production.IsAuthorized,
+ ProductionDynamicActivationAuthorized = production.IsAuthorized,
+ ProductionDynamicAuthorizationReason = production.Reason,
+ ProductionQualifiedDynamicMemberCount = production.MemberReferences.Count,
+ ProductionQualifiedRcbReference = production.RcbReference,
+ PolicyWarnings = policyWarnings.ToArray()
+ };
+ }
+
+ private static ProductionDynamicAuthorization EvaluateProductionAuthorization(
+ MmsDynamicReportProductionPlanningContext? context,
+ MmsReportAssociationCapability capability,
+ bool dynamicIntent)
+ {
+ if (!dynamicIntent)
+ return ProductionDynamicAuthorization.Denied("Dynamic BRCB/URCB acquisition is disabled by planner policy.");
+ if (context is null)
+ return ProductionDynamicAuthorization.Denied("No persisted ProductionEligible qualification context was supplied.");
+
+ if (!MmsDynamicReportQualificationProfilePolicy.CanUseForProductionPlanning(
+ context.Profile,
+ context.CurrentIdentity,
+ out var productionReason))
+ {
+ return ProductionDynamicAuthorization.Denied(productionReason);
+ }
+
+ if (!capability.MayAttemptDynamicReports)
+ {
+ return ProductionDynamicAuthorization.Denied(
+ "The current MMS association does not satisfy the dynamic-report capability gate.");
+ }
+
+ var envelope = context.Profile.AcceptedEnvelope;
+ var activation = context.Profile.RcbActivationProof;
+ var report = context.Profile.InformationReportProof;
+ if (envelope is null || activation is null || report is null)
+ return ProductionDynamicAuthorization.Denied("Production profile is missing dynamic qualification evidence.");
+
+ if (!SameReference(activation.RcbReference, report.RcbReference))
+ return ProductionDynamicAuthorization.Denied("Stored RCB activation and InformationReport RCB identities differ.");
+ if (!SameReference(activation.DataSetReference, report.DataSetReference))
+ return ProductionDynamicAuthorization.Denied("Stored RCB activation and InformationReport DataSet identities differ.");
+ if (!ExactMemberSequenceEquals(activation.MemberReferences, report.MemberReferences))
+ return ProductionDynamicAuthorization.Denied("Stored activation/report member sequences differ.");
+ if (!IsOrderedMemberSubset(report.MemberReferences, envelope.ExactProvenMemberReferences))
+ return ProductionDynamicAuthorization.Denied("InformationReport members are outside the accepted qualified envelope.");
+ if (report.MemberReferences.Count == 0 || report.MemberReferences.Count > envelope.ProvenMemberCount)
+ return ProductionDynamicAuthorization.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 ProductionDynamicAuthorization.Denied("InformationReport production member evidence is empty or duplicated.");
+ }
+
+ return new ProductionDynamicAuthorization(
+ true,
+ $"{productionReason} Automatic dynamic planning is scoped to the exact proven InformationReport RCB/member set.",
+ 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 AutomaticMonitoringOptions(
- MmsHybridReportAcquisitionOptions source)
+ MmsHybridReportAcquisitionOptions source,
+ ProductionDynamicAuthorization production)
=> new()
{
MaxStaticReportPlans = source.MaxStaticReportPlans,
- MaxDynamicReportPlans = source.MaxDynamicReportPlans,
- MaxDynamicMembersPerReport = source.MaxDynamicMembersPerReport,
+ MaxDynamicReportPlans = production.IsAuthorized
+ ? Math.Min(source.MaxDynamicReportPlans, 1)
+ : source.MaxDynamicReportPlans,
+ MaxDynamicMembersPerReport = production.IsAuthorized
+ ? Math.Min(source.MaxDynamicMembersPerReport, production.MemberReferences.Count)
+ : source.MaxDynamicMembersPerReport,
RequireExactAvailabilityEvidence = source.RequireExactAvailabilityEvidence,
AllowCallerOwnedReports = source.AllowCallerOwnedReports,
AllowStaticBrcb = source.AllowStaticBrcb,
AllowStaticUrcb = source.AllowStaticUrcb,
- // P6.2-B: automatic full dynamic DataSet activation is quarantined after
- // physical evidence that a successful single-member NVL probation does not
- // guarantee association survival for the full member set.
- AllowDynamicBrcb = false,
- AllowDynamicUrcb = false,
+ AllowDynamicBrcb = production.IsAuthorized && source.AllowDynamicBrcb,
+ AllowDynamicUrcb = production.IsAuthorized && source.AllowDynamicUrcb,
AllowPollingFallback = source.AllowPollingFallback
};
+ private static bool ValidateProductionDynamicSegments(
+ MmsHybridReportAcquisitionPlan plan,
+ ProductionDynamicAuthorization production,
+ out string reason)
+ {
+ var dynamicSegments = plan.Segments
+ .Where(segment => segment.Kind is MmsHybridAcquisitionKind.DynamicBrcb or MmsHybridAcquisitionKind.DynamicUrcb)
+ .ToArray();
+
+ if (dynamicSegments.Length == 0)
+ {
+ reason = "No production dynamic segment was needed or safely available for this request.";
+ return true;
+ }
+ if (dynamicSegments.Length > 1)
+ {
+ reason = "The first production consumer is limited to one proven dynamic RCB group.";
+ return false;
+ }
+
+ var segment = dynamicSegments[0];
+ if (!SameReference(segment.ReportControlReference, production.RcbReference))
+ {
+ reason = $"Planner selected RCB {segment.ReportControlReference} instead of proven RCB {production.RcbReference}.";
+ return false;
+ }
+ if (segment.ReportPlan is null || segment.ReportPlan.DynamicPoints.Count == 0)
+ {
+ reason = "Dynamic segment is missing an exact resolved production member set.";
+ return false;
+ }
+
+ var plannedMembers = segment.ReportPlan.DynamicPoints
+ .Select(point => NormalizeMms(point.MmsReference))
+ .ToArray();
+ if (!IsOrderedMemberSubset(plannedMembers, production.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 proven production evidence scope.";
+ return true;
+ }
+
private static bool HasConfiguredStaticDataSetEvidence(MmsRcbAvailabilitySnapshot snapshot)
=> snapshot.DataSetProbeState == MmsRcbDataSetProbeState.ReadSucceeded &&
snapshot.DataSetDirectorySuccess &&
@@ -160,7 +366,7 @@ private static void RestoreFreshAttributeEvidence(
candidate.ProbeDiagnostics.AddRange(snapshot.ProbeDiagnostics);
candidate.Status = segment.Kind is MmsHybridAcquisitionKind.StaticBrcb or MmsHybridAcquisitionKind.StaticUrcb
? "P6.1 baseline-static fresh availability snapshot"
- : "P6.2-B capability-qualified dynamic fresh availability snapshot";
+ : "G2.6 ProductionEligible dynamic fresh availability snapshot";
}
}
@@ -181,6 +387,60 @@ private static void AddIfExposed(
}
}
+ 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 ProductionDynamicAuthorization(
+ bool IsAuthorized,
+ string Reason,
+ string RcbReference,
+ IReadOnlyList MemberReferences)
+ {
+ public static ProductionDynamicAuthorization Denied(string reason)
+ => new(false, reason, string.Empty, Array.Empty());
+ }
}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsG26ProductionDynamicConsumerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsG26ProductionDynamicConsumerTests.cs
new file mode 100644
index 0000000..01ce7a2
--- /dev/null
+++ b/tests/AR.Iec61850.Tests/Mms/MmsG26ProductionDynamicConsumerTests.cs
@@ -0,0 +1,419 @@
+using AR.Iec61850.Acse;
+using AR.Iec61850.Discovery;
+using AR.Iec61850.Mms;
+
+namespace AR.Iec61850.Tests.Mms;
+
+public sealed class MmsG26ProductionDynamicConsumerTests
+{
+ [Fact]
+ public void InformationReportProven_RemainsQuarantinedForAutomaticDynamicPlanning()
+ {
+ var profile = BuildInformationReportProvenProfile();
+ var result = Build(
+ [Signal(1)],
+ DynamicAvailability(ProvenRcbReference),
+ DynamicInventory(ProvenRcbReference),
+ Directory(1),
+ new MmsDynamicReportProductionPlanningContext
+ {
+ Profile = profile,
+ CurrentIdentity = Identity()
+ });
+
+ Assert.True(result.AssociationCapability.MayAttemptDynamicReports);
+ Assert.False(result.ProductionDynamicActivationAuthorized);
+ Assert.True(result.AutomaticDynamicActivationQuarantined);
+ Assert.Equal(0, result.AcquisitionPlan.DynamicUrcbSignalCount);
+ Assert.Equal(1, result.AcquisitionPlan.PollingFallbackSignalCount);
+ Assert.Contains("not ProductionEligible", result.ProductionDynamicAuthorizationReason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ProductionEligible_ExactProvenRcbAndMembers_AuthorizeDynamicUrcb()
+ {
+ var profile = BuildProductionEligibleProfile();
+ var result = Build(
+ [Signal(1), Signal(2)],
+ DynamicAvailability(ProvenRcbReference),
+ DynamicInventory(ProvenRcbReference),
+ Directory(1, 2, 3, 4),
+ new MmsDynamicReportProductionPlanningContext
+ {
+ Profile = profile,
+ CurrentIdentity = Identity()
+ });
+
+ Assert.True(result.ProductionDynamicActivationAuthorized);
+ Assert.False(result.AutomaticDynamicActivationQuarantined);
+ Assert.Equal(4, result.ProductionQualifiedDynamicMemberCount);
+ Assert.Equal(ProvenRcbReference, result.ProductionQualifiedRcbReference);
+ Assert.Equal(2, result.AcquisitionPlan.DynamicUrcbSignalCount);
+ Assert.Equal(0, result.AcquisitionPlan.PollingFallbackSignalCount);
+ Assert.Equal(MmsHybridAcquisitionPlanStatus.FullReportCoverage, result.AcquisitionPlan.Status);
+
+ var segment = Assert.Single(
+ result.AcquisitionPlan.Segments,
+ x => x.Kind == MmsHybridAcquisitionKind.DynamicUrcb);
+ Assert.Equal(ProvenRcbReference, segment.ReportControlReference);
+ Assert.Equal(
+ [Member(1), Member(2)],
+ segment.ReportPlan!.DynamicPoints.Select(point => point.MmsReference).ToArray());
+ }
+
+ [Fact]
+ public void ProductionEligible_UnprovenMember_RemainsOnPolling()
+ {
+ var result = Build(
+ [Signal(1), OutsideSignal()],
+ DynamicAvailability(ProvenRcbReference),
+ DynamicInventory(ProvenRcbReference),
+ Directory(1, 2, 3, 4, 99),
+ ProductionContext());
+
+ Assert.True(result.ProductionDynamicActivationAuthorized);
+ Assert.Equal(1, result.AcquisitionPlan.DynamicUrcbSignalCount);
+ Assert.Equal(1, result.AcquisitionPlan.PollingFallbackSignalCount);
+ Assert.Equal(MmsHybridAcquisitionPlanStatus.HybridReportAndPolling, result.AcquisitionPlan.Status);
+
+ 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);
+ Assert.DoesNotContain(dynamic.ReportPlan.DynamicPoints, point =>
+ point.MmsReference.Equals(Member(99), StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void ProductionEligible_IdentityMismatch_FailsClosedToPolling()
+ {
+ var context = ProductionContext() with
+ {
+ CurrentIdentity = Identity() with { ModelFingerprint = "sha256:different-model" }
+ };
+
+ var result = Build(
+ [Signal(1)],
+ DynamicAvailability(ProvenRcbReference),
+ DynamicInventory(ProvenRcbReference),
+ Directory(1),
+ context);
+
+ Assert.False(result.ProductionDynamicActivationAuthorized);
+ 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 ProductionEligible_DifferentFreeRcb_DoesNotSubstituteForProvenRcb()
+ {
+ const string otherRcb = "LD0/LLN0.RP.OtherFree01";
+ var result = Build(
+ [Signal(1)],
+ DynamicAvailability(otherRcb),
+ DynamicInventory(otherRcb),
+ Directory(1),
+ ProductionContext());
+
+ Assert.True(result.ProductionDynamicActivationAuthorized);
+ Assert.Equal(0, result.AcquisitionPlan.DynamicUrcbSignalCount);
+ Assert.Equal(1, result.AcquisitionPlan.PollingFallbackSignalCount);
+ Assert.DoesNotContain(result.AcquisitionPlan.Segments, segment =>
+ segment.ReportControlReference.Equals(otherRcb, StringComparison.OrdinalIgnoreCase) &&
+ segment.Kind == MmsHybridAcquisitionKind.DynamicUrcb);
+ }
+
+ [Fact]
+ public void TamperedProductionProfile_MemberOutsideEnvelope_IsRejectedByConsumer()
+ {
+ var profile = BuildProductionEligibleProfile();
+ var tamperedReport = profile.InformationReportProof! with
+ {
+ MemberReferences = [Member(1), Member(99)],
+ ReportAuthoritativePointCount = 2
+ };
+ var tampered = profile with { InformationReportProof = tamperedReport };
+
+ var result = Build(
+ [Signal(1)],
+ DynamicAvailability(ProvenRcbReference),
+ DynamicInventory(ProvenRcbReference),
+ Directory(1, 99),
+ new MmsDynamicReportProductionPlanningContext
+ {
+ Profile = tampered,
+ CurrentIdentity = Identity()
+ });
+
+ Assert.False(result.ProductionDynamicActivationAuthorized);
+ Assert.True(result.AutomaticDynamicActivationQuarantined);
+ Assert.Equal(0, result.AcquisitionPlan.DynamicUrcbSignalCount);
+ Assert.Equal(1, result.AcquisitionPlan.PollingFallbackSignalCount);
+ }
+
+ private static MmsCapabilityAwareHybridReportAcquisitionPlan Build(
+ IReadOnlyList signals,
+ MmsRcbAvailabilityResult availability,
+ MmsReportInventory inventory,
+ MmsIedModelDirectory directory,
+ MmsDynamicReportProductionPlanningContext context)
+ {
+ var catalog = new Iec61850SignalCatalogDocument
+ {
+ IedName = "G26_SYNTHETIC_IED",
+ Source = "Project-owned G2.6 production consumer fixture",
+ Signals = signals
+ };
+
+ return MmsCapabilityAwareHybridReportAcquisitionPlanner.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 MmsDynamicReportProductionPlanningContext ProductionContext()
+ => new()
+ {
+ Profile = BuildProductionEligibleProfile(),
+ CurrentIdentity = Identity()
+ };
+
+ private static MmsDynamicReportQualificationProfile BuildProductionEligibleProfile()
+ => MmsDynamicReportQualificationProfilePolicy.MarkProductionEligible(
+ BuildInformationReportProvenProfile(),
+ Identity(),
+ new MmsDynamicReportProductionAcceptance
+ {
+ FieldEvidenceId = "g2.6-synthetic-regression",
+ ObservedAtUtc = Time(30),
+ ControlRegressionPassed = true,
+ StaticReportingRegressionPassed = true,
+ DynamicInformationReportRegressionPassed = true,
+ PollingAuthorityGuardPassed = true,
+ ReconnectRegressionPassed = true,
+ QualityRegressionPassed = true,
+ NoRepeatedMutationLoopPassed = true
+ });
+
+ private static MmsDynamicReportQualificationProfile BuildInformationReportProvenProfile()
+ {
+ var members = Members(1, 2, 3, 4);
+ var assessment = MmsDynamicDataSetQualificationLadder.Assess(
+ [
+ new MmsDynamicDataSetQualificationAttemptEvidence
+ {
+ AttemptId = "g26-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-envelope-4");
+ var profile = MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile(
+ Identity(),
+ envelope,
+ assessment,
+ new MmsDynamicReportCapacityEvidence
+ {
+ ObservedFreeBrcbSlots = 0,
+ ObservedFreeUrcbSlots = 1,
+ ObservedAtUtc = Time(2),
+ EvidenceId = "g26-capacity"
+ },
+ "g26-envelope-evidence",
+ Time(3));
+
+ var activated = MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof(
+ profile,
+ Identity(),
+ new MmsDynamicRcbActivationProof
+ {
+ EvidenceId = "g26-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-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:synthetic",
+ ModelFingerprint = "sha256:g26-model-001",
+ Manufacturer = "Example",
+ Model = "SyntheticIED",
+ 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 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 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-24T00:00:00Z").AddMinutes(minutes);
+
+ private const string ProvenRcbReference = "LD0/LLN0.RP.Unbuffer02";
+ private const string ProvenDataSetReference = "LD0/LLN0.AR_G2Q";
+}