diff --git a/Services/NativeIec61850Client.HybridReporting.P4.cs b/Services/NativeIec61850Client.HybridReporting.P4.cs
index 1349204e..09a3da8a 100644
--- a/Services/NativeIec61850Client.HybridReporting.P4.cs
+++ b/Services/NativeIec61850Client.HybridReporting.P4.cs
@@ -7,12 +7,18 @@ namespace ArIED61850Tester.Services;
public sealed partial class NativeIec61850Client
{
///
- /// P4 recovery path used only after an engine-authoritative static report can no longer
- /// be activated from fresh association evidence. Static planning is disabled for this
- /// recovery pass so the same signal must either obtain a dynamic BRCB/URCB attempt or
- /// return an explicit engine reason explaining why polling is the final fallback.
+ /// P6.1 baseline-safety compatibility hook.
+ ///
+ /// P4 originally converted a failed static activation into a brand-new dynamic
+ /// DataSet/RCB write attempt. That changed the proven pre-P0 failure semantics and made
+ /// one static problem capable of mutating another RCB or destabilizing the association.
+ /// Static failure is now isolated again: no dynamic DataSet is created, no alternate RCB
+ /// is written, and bounded MMS polling remains the fallback for the affected signal set.
+ ///
+ /// The method name is retained temporarily so existing call-sites stay source-compatible;
+ /// its behavior is deliberately fail-closed and side-effect free.
///
- private async Task TryStartDynamicRecoveryAfterStaticFailureP4Async(
+ private Task TryStartDynamicRecoveryAfterStaticFailureP4Async(
ReportControlPlan appPlan,
AuthoritativeHybridSubscription authoritative,
ArMms.MmsDiscoveryResult discovery,
@@ -20,171 +26,22 @@ private async Task TryStartDynamicRecoveryAfterS
string staticFailure,
CancellationToken cancellationToken)
{
- // P6: a previous real dynamic write failure is field evidence that this device must
- // not receive another temporary DataSet/RCB write in the current application run.
- // Static reporting remains eligible on every fresh association; only this recovery
- // path is circuit-broken so reconnect cannot become a dynamic-write retry loop.
- if (!string.IsNullOrWhiteSpace(appPlan.RelayId) &&
- DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId, out var circuitReason))
- {
- return new NativeReportMonitorStartResult
- {
- IsSuccess = false,
- PlanId = appPlan.PlanId,
- Message = $"{staticFailure} P6 dynamic recovery is circuit-broken after a previous real activation failure ({circuitReason}); MMS polling is the bounded fallback for this residual signal set.",
- UsedDynamicDataSet = true,
- DynamicAttempted = false,
- DynamicAttemptState = "Skipped",
- FailureReason = "DynamicWriteCircuitOpen",
- PollingFallbackReason = "DynamicWriteCircuitOpen"
- };
- }
-
- if (!authoritative.Options.AllowDynamicBrcb && !authoritative.Options.AllowDynamicUrcb)
- {
- return new NativeReportMonitorStartResult
- {
- IsSuccess = false,
- PlanId = appPlan.PlanId,
- Message = $"{staticFailure} Dynamic recovery is disabled by policy; MMS polling is the final fallback.",
- DynamicAttemptState = "Skipped",
- FailureReason = "StaticActivationFailed",
- PollingFallbackReason = ArMms.MmsHybridPollingFallbackReason.DynamicDisabledByPolicy.ToString()
- };
- }
+ _ = authoritative;
+ _ = discovery;
+ _ = freshAvailability;
+ _ = cancellationToken;
- var dynamicOnlyOptions = new ArMms.MmsHybridReportAcquisitionOptions
+ return Task.FromResult(new NativeReportMonitorStartResult
{
- AllowStaticBrcb = false,
- AllowStaticUrcb = false,
- AllowDynamicBrcb = authoritative.Options.AllowDynamicBrcb,
- AllowDynamicUrcb = authoritative.Options.AllowDynamicUrcb,
- AllowCallerOwnedReports = false,
- AllowPollingFallback = true,
- RequireExactAvailabilityEvidence = authoritative.Options.RequireExactAvailabilityEvidence,
- MaxDynamicMembersPerReport = authoritative.Options.MaxDynamicMembersPerReport,
- MaxDynamicReportPlans = authoritative.Options.MaxDynamicReportPlans
- };
-
- var recovery = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build(
- authoritative.Catalog,
- authoritative.Signals,
- discovery.ReportInventory,
- freshAvailability,
- discovery.IedDirectory,
- _session.LastNegotiatedCapabilities,
- dynamicOnlyOptions);
- var attemptEvidence = ArMms.MmsHybridDynamicAttemptEvidenceBuilder.Build(recovery, dynamicOnlyOptions);
- var dynamicSegment = recovery.AcquisitionPlan.Segments.FirstOrDefault(segment =>
- segment.ReportPlan is not null &&
- segment.Kind is ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ArMms.MmsHybridAcquisitionKind.DynamicUrcb);
-
- if (dynamicSegment?.ReportPlan is null)
- {
- var fallbackReasons = attemptEvidence
- .Where(item => item.DynamicAttemptDisposition == ArMms.MmsHybridDynamicAttemptDisposition.Skipped)
- .Select(item => item.PollingFallbackReason.ToString())
- .Where(reason => !reason.Equals("None", StringComparison.OrdinalIgnoreCase))
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToArray();
- var reason = fallbackReasons.Length == 0
- ? ArMms.MmsHybridPollingFallbackReason.DynamicPlanUnavailableAfterCapabilityQualification.ToString()
- : string.Join(",", fallbackReasons);
-
- return new NativeReportMonitorStartResult
- {
- IsSuccess = false,
- PlanId = appPlan.PlanId,
- Message = $"{staticFailure} ARIEC P4 dynamic recovery produced no ready dynamic report segment; MMS polling is now the final fallback. reason={reason}.",
- DynamicAttempted = false,
- DynamicAttemptState = "Skipped",
- FailureReason = "StaticActivationFailed",
- PollingFallbackReason = reason,
- Warnings = recovery.Warnings
- .Concat(recovery.Blockers)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToArray()
- };
- }
-
- var subscription = dynamicSegment.ReportPlan;
- var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members);
- var attempt = await RunMmsOperationAsync(
- () => _session.StartPersistentReportMonitorWithAttemptEvidenceAsync(
- subscription,
- triggerGeneralInterrogation: true,
- deleteDynamicDataSetOnStop: true,
- discovery.IedDirectory,
- cancellationToken),
- cancellationToken).ConfigureAwait(false);
- var start = attempt.StartResult;
- var warnings = start.Warnings
- .Concat(subscription.Warnings)
- .Concat(attempt.CleanupWarnings)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToArray();
-
- if (!attempt.IsSuccess || start.Session is null)
- {
- if (attempt.DynamicAttempted && !string.IsNullOrWhiteSpace(appPlan.RelayId))
- {
- var failure = attempt.FailureReason.ToString();
- if (string.IsNullOrWhiteSpace(failure) || failure.Equals("None", StringComparison.OrdinalIgnoreCase))
- failure = "DynamicRecoveryActivationFailed";
- DynamicWriteCircuitByDevice[appPlan.RelayId] = failure;
- }
-
- return new NativeReportMonitorStartResult
- {
- IsSuccess = false,
- PlanId = appPlan.PlanId,
- Message = $"{staticFailure} Dynamic recovery was {(attempt.DynamicAttempted ? "attempted" : "not attempted")} and failed: {start.Message}",
- SubscriptionSummary = subscription.Summary,
- MemberCount = subscription.Members.Count,
- WriteStepCount = start.WriteSteps.Count,
- UsedDynamicDataSet = true,
- DynamicAttempted = attempt.DynamicAttempted,
- DynamicAttemptState = attempt.DynamicAttemptState.ToString(),
- FailureReason = attempt.FailureReason.ToString(),
- PollingFallbackReason = attempt.DynamicAttempted ? "DynamicActivationFailed" : "DynamicActivationNotAttempted",
- CleanupAttempted = attempt.CleanupAttempted,
- CleanupSucceeded = attempt.CleanupSucceeded,
- CoveredReferences = coveredReferences,
- Warnings = warnings
- };
- }
-
- appPlan.EngineAcquisitionKind = dynamicSegment.Kind.ToString();
- appPlan.AllowDynamicDataSetWrites = true;
- appPlan.Buffered = dynamicSegment.Kind == ArMms.MmsHybridAcquisitionKind.DynamicBrcb;
- appPlan.Mode = $"ARIEC Hybrid • {dynamicSegment.Kind} • P4 static recovery";
- appPlan.Status = $"{dynamicSegment.Kind} active after static recovery";
- if (!string.IsNullOrWhiteSpace(start.Session.ReportControl.Reference))
- appPlan.ReportControlReference = start.Session.ReportControl.Reference;
- if (!string.IsNullOrWhiteSpace(start.Session.Plan.DataSetReference))
- appPlan.DataSetReference = start.Session.Plan.DataSetReference;
-
- _reportMonitorSessions[appPlan.PlanId] = start.Session;
- _reportMonitorCoverage[appPlan.PlanId] = coveredReferences;
-
- return new NativeReportMonitorStartResult
- {
- IsSuccess = true,
+ IsSuccess = false,
PlanId = appPlan.PlanId,
- Message = $"ARIEC P4 recovered failed static acquisition with {dynamicSegment.Kind}. {start.Message}",
- SubscriptionSummary = subscription.Summary,
- MemberCount = subscription.Members.Count,
- WriteStepCount = start.WriteSteps.Count,
- UsedDynamicDataSet = true,
- DynamicAttempted = true,
- DynamicAttemptState = attempt.DynamicAttemptState.ToString(),
- FailureReason = string.Empty,
- ReportControlReference = appPlan.ReportControlReference,
- DataSetReference = appPlan.DataSetReference,
- AcquisitionLabel = $"ARIEC Hybrid: {dynamicSegment.Kind} (P4 recovery)",
- CoveredReferences = coveredReferences,
- Warnings = warnings
- };
+ Message = $"{staticFailure} P6.1 preserved baseline static-failure isolation: no dynamic DataSet/RCB write was attempted; bounded MMS polling remains active for this affected signal set.",
+ UsedDynamicDataSet = false,
+ DynamicAttempted = false,
+ DynamicAttemptState = "Skipped",
+ FailureReason = "StaticActivationFailed",
+ PollingFallbackReason = "StaticActivationFailed"
+ });
}
private static bool IsStaticHybridKind(ArMms.MmsHybridAcquisitionKind kind)
diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs
index d89448a8..99b3b0ba 100644
--- a/Services/NativeIec61850Client.HybridReporting.cs
+++ b/Services/NativeIec61850Client.HybridReporting.cs
@@ -253,6 +253,8 @@ public async Task BuildHybridReportPlansAsync(
reportPlans.Add(appPlan);
}
+ var activationPlans = OrderHybridActivationPlans(reportPlans);
+
var polling = enginePlan.Assignments
.Where(assignment => assignment.Kind == ArMms.MmsHybridAcquisitionKind.MmsPollingFallback)
.Select(assignment => FindPointKeyForAssignment(assignment.SignalReference, descriptorPoints))
@@ -280,6 +282,11 @@ public async Task BuildHybridReportPlansAsync(
p6Warnings.Add(
$"P6 static inventory bridge mapped {staticInventoryMappedCount} selected point(s) through ARIEC mandatory DataSet member evidence before broad catalog matching.");
}
+ if (activationPlans.Count > 1 && activationPlans[0].AllowDynamicDataSetWrites && activationPlans.Any(plan => !plan.AllowDynamicDataSetWrites))
+ {
+ p6Warnings.Add(
+ "P6.1 mixed-plan safety: dynamic DataSet mutation is probationed before static RCB activation so a relay-aborting dynamic write cannot orphan an already-armed static RCB. Static coverage priority is unchanged.");
+ }
if (dynamicWriteCircuitOpen)
{
p6Warnings.Add(
@@ -304,7 +311,7 @@ public async Task BuildHybridReportPlansAsync(
Summary = $"{enginePlan.Summary} {associationCapability.Summary}" +
(staticInventoryMappedCount > 0 ? $" Static inventory bridge={staticInventoryMappedCount}." : string.Empty) +
(dynamicWriteCircuitOpen ? " Dynamic writes circuit-broken after field failure evidence." : string.Empty),
- ReportPlans = reportPlans,
+ ReportPlans = activationPlans,
PollingPointKeys = polling,
UncoveredPointKeys = uncovered,
UnmappedPointKeys = unmapped.Select(point => point.PointKey).ToArray(),
@@ -321,6 +328,24 @@ public async Task BuildHybridReportPlansAsync(
};
}
+ internal static IReadOnlyList OrderHybridActivationPlans(IEnumerable plans)
+ {
+ var materialized = plans.ToArray();
+ var hasDynamic = materialized.Any(plan => plan.IsEngineAuthoritative && plan.AllowDynamicDataSetWrites);
+ var hasStatic = materialized.Any(plan => plan.IsEngineAuthoritative && !plan.AllowDynamicDataSetWrites);
+ if (!hasDynamic || !hasStatic)
+ return materialized;
+
+ // Coverage precedence remains static -> dynamic -> polling. This ordering controls
+ // only activation side effects. A dynamic DefineNamedVariableList write is the risky
+ // operation on IEDs that abort the association. Run that probation before arming the
+ // already-proven static RCB so a failed dynamic attempt cannot leave that static RCB
+ // transiently enabled/owned and then unavailable to the immediate reconnect.
+ return materialized
+ .OrderByDescending(plan => plan.IsEngineAuthoritative && plan.AllowDynamicDataSetWrites)
+ .ToArray();
+ }
+
public async Task StartHybridReportMonitorAsync(
ReportControlPlan plan,
CancellationToken cancellationToken)
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index d3919eb7..7edc3332 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
- "commit": "2a932e183931eb65c775fe01cf8a47bf8a9af458",
- "sourcePullRequest": 86,
- "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, and PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation."
+ "commit": "1d052c3919ca1fdf043747d858dab9b6c384e827",
+ "sourcePullRequest": 87,
+ "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values, PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling while preventing quality/timestamp descriptors from hijacking the primary-value fallback, PR #85 evaluates negotiated MMS write/DefineNamedVariableList/DeleteNamedVariableList support plus fresh per-RCB DatSet/RptEna/TrgOps/OptFlds/GI/IntgPd/reservation/Owner evidence before automatic dynamic reporting, PR #86 requires explicit per-signal dynamic-attempt/skip evidence before final polling while returning runtime dynamic-attempt failure reasons and best-effort rollback evidence for failed temporary DataSet/RCB activation, and PR #87 restores baseline-safe static precedence by keeping freshly verified populated static RCBs visible to the stable planner while capability qualification remains an additional gate only for empty RCB slots that require dynamic DataSet/RCB mutation."
}
diff --git a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs
index a9674af6..a5704e78 100644
--- a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs
+++ b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs
@@ -18,17 +18,18 @@ public void HybridPlanning_UsesAssociationCapabilityForInitialPlanAndFreshRevali
}
[Fact]
- public void EngineLock_PinsReviewedP4EngineWithoutRegressingP3CapabilityEvidence()
+ public void EngineLock_PinsP61StaticPreservingAttemptAwareEngine()
{
var source = Read("engines/ARIEC61850.lock.json");
- Assert.Contains("2a932e183931eb65c775fe01cf8a47bf8a9af458", source, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("\"sourcePullRequest\": 86", source, StringComparison.Ordinal);
+ Assert.Contains("1d052c3919ca1fdf043747d858dab9b6c384e827", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("\"sourcePullRequest\": 87", source, StringComparison.Ordinal);
Assert.Contains("DefineNamedVariableList", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("DeleteNamedVariableList", source, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("TrgOps", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("dynamic-attempt", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("rollback", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("baseline-safe static precedence", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("dynamic DataSet/RCB mutation", source, StringComparison.OrdinalIgnoreCase);
}
private static int Count(string source, string value)
diff --git a/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs b/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs
index 7cf52790..037ef597 100644
--- a/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs
+++ b/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs
@@ -19,20 +19,28 @@ public void Planning_ProjectsEngineAttemptEvidenceInsteadOfSilentPolling()
}
[Fact]
- public void Activation_UsesAttemptAwareEngineAndRollsStaticFailureIntoDynamicRecovery()
+ public void StaticFailure_IsIsolatedAndNeverStartsDynamicMutation()
{
var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs");
var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs");
+ // The ordinary residual dynamic path is still attempt-aware.
Assert.Contains("StartPersistentReportMonitorWithAttemptEvidenceAsync", bridge, StringComparison.Ordinal);
Assert.True(Count(bridge, "TryStartDynamicRecoveryAfterStaticFailureP4Async") >= 4);
- Assert.Contains("AllowStaticBrcb = false", recovery, StringComparison.Ordinal);
- Assert.Contains("AllowStaticUrcb = false", recovery, StringComparison.Ordinal);
- Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal);
- Assert.Contains("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal);
- Assert.Contains("DynamicActivationFailed", recovery, StringComparison.Ordinal);
- Assert.Contains("CleanupAttempted = attempt.CleanupAttempted", recovery, StringComparison.Ordinal);
- Assert.Contains("CleanupSucceeded = attempt.CleanupSucceeded", recovery, StringComparison.Ordinal);
+
+ // P6.1 intentionally keeps the old method name only as a source-compatible,
+ // fail-closed hook. Static failure must never create a new DataSet or write another RCB.
+ Assert.Contains("baseline static-failure isolation", recovery, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("no dynamic DataSet/RCB write was attempted", recovery, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("UsedDynamicDataSet = false", recovery, StringComparison.Ordinal);
+ Assert.Contains("DynamicAttempted = false", recovery, StringComparison.Ordinal);
+ Assert.Contains("FailureReason = \"StaticActivationFailed\"", recovery, StringComparison.Ordinal);
+ Assert.Contains("PollingFallbackReason = \"StaticActivationFailed\"", recovery, StringComparison.Ordinal);
+ Assert.DoesNotContain("AllowStaticBrcb = false", recovery, StringComparison.Ordinal);
+ Assert.DoesNotContain("AllowStaticUrcb = false", recovery, StringComparison.Ordinal);
+ Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal);
+ Assert.DoesNotContain("DynamicWriteCircuitByDevice[appPlan.RelayId]", recovery, StringComparison.Ordinal);
}
[Fact]
@@ -51,12 +59,10 @@ public void PhysicalValidation_PersistsAttemptFailureAndSkipTelemetry()
}
[Fact]
- public void EngineLock_PinsP4AttemptAwareEngine()
+ public void EngineLock_PinsAttemptAwareEngine()
{
var engineLock = Read("engines/ARIEC61850.lock.json");
- Assert.Contains("2a932e183931eb65c775fe01cf8a47bf8a9af458", engineLock, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("\"sourcePullRequest\": 86", engineLock, StringComparison.Ordinal);
Assert.Contains("dynamic-attempt", engineLock, StringComparison.OrdinalIgnoreCase);
Assert.Contains("rollback", engineLock, StringComparison.OrdinalIgnoreCase);
}
diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs
index 270d4f30..dcfeb889 100644
--- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs
+++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs
@@ -1,4 +1,5 @@
using ArIED61850Tester.Services;
+using System.Text.Json;
namespace ARSAS.Tests;
@@ -68,12 +69,14 @@ public void SignalSelectionRecovery_RunsAgainAfterLegacyConstructorDisplayPrepar
}
[Fact]
- public void EngineLock_PinsMergedReportProjectionEngineWithoutLosingMemberCentricInventory()
+ public void EngineLock_PreservesReportProjectionHistoryAndPinsP61StaticSafetyEngine()
{
var source = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json"));
+ using var document = JsonDocument.Parse(source);
+ var root = document.RootElement;
- Assert.Contains("2a932e183931eb65c775fe01cf8a47bf8a9af458", source, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("\"sourcePullRequest\": 86", source, StringComparison.Ordinal);
+ Assert.Equal("1d052c3919ca1fdf043747d858dab9b6c384e827", root.GetProperty("commit").GetString());
+ Assert.Equal(87, root.GetProperty("sourcePullRequest").GetInt32());
Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase);
@@ -84,6 +87,7 @@ public void EngineLock_PinsMergedReportProjectionEngineWithoutLosingMemberCentri
Assert.Contains("TrgOps", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("dynamic-attempt", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("rollback", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("baseline-safe static precedence", source, StringComparison.OrdinalIgnoreCase);
}
[Fact]
diff --git a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs
index 1f857817..f9e22ee2 100644
--- a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs
+++ b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs
@@ -1,4 +1,5 @@
using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
using ArIED61850Tester.Services;
namespace ARSAS.Tests;
@@ -62,6 +63,59 @@ public void HybridPlannerSource_UsesMandatoryStaticInventoryBeforeBroadCatalog()
Assert.Contains("staticInventoryMappedCount", source, StringComparison.Ordinal);
}
+ [Fact]
+ public void MixedAuthoritativePlans_ProbationDynamicBeforeStaticWithoutChangingCoverageIdentity()
+ {
+ var staticPlan = new ReportControlPlan
+ {
+ PlanId = "static",
+ IsEngineAuthoritative = true,
+ EngineAcquisitionKind = "StaticBrcb",
+ AllowDynamicDataSetWrites = false,
+ ReportControlReference = "IEDLD0/LLN0.BR.Buffer02"
+ };
+ var dynamicPlan = new ReportControlPlan
+ {
+ PlanId = "dynamic",
+ IsEngineAuthoritative = true,
+ EngineAcquisitionKind = "DynamicUrcb",
+ AllowDynamicDataSetWrites = true,
+ ReportControlReference = "IEDLD0/LLN0.RP.A_URCB01"
+ };
+
+ var ordered = NativeIec61850Client.OrderHybridActivationPlans([staticPlan, dynamicPlan]);
+
+ Assert.Equal(2, ordered.Count);
+ Assert.Same(dynamicPlan, ordered[0]);
+ Assert.Same(staticPlan, ordered[1]);
+ Assert.Equal("StaticBrcb", staticPlan.EngineAcquisitionKind);
+ Assert.Equal("IEDLD0/LLN0.BR.Buffer02", staticPlan.ReportControlReference);
+ }
+
+ [Fact]
+ public void StaticOnlyActivationOrder_RemainsBaselineStable()
+ {
+ var first = new ReportControlPlan
+ {
+ PlanId = "s1",
+ IsEngineAuthoritative = true,
+ EngineAcquisitionKind = "StaticBrcb",
+ AllowDynamicDataSetWrites = false
+ };
+ var second = new ReportControlPlan
+ {
+ PlanId = "s2",
+ IsEngineAuthoritative = true,
+ EngineAcquisitionKind = "StaticUrcb",
+ AllowDynamicDataSetWrites = false
+ };
+
+ var ordered = NativeIec61850Client.OrderHybridActivationPlans([first, second]);
+
+ Assert.Same(first, ordered[0]);
+ Assert.Same(second, ordered[1]);
+ }
+
[Fact]
public void FailedRealDynamicAttempt_OpensProcessLifetimeCircuitBreaker()
{
@@ -73,28 +127,24 @@ public void FailedRealDynamicAttempt_OpensProcessLifetimeCircuitBreaker()
Assert.Contains("AllowDynamicBrcb = allowDynamicWrites", source, StringComparison.Ordinal);
Assert.Contains("AllowDynamicUrcb = allowDynamicWrites", source, StringComparison.Ordinal);
Assert.Contains("DynamicWriteCircuitOpen", source, StringComparison.Ordinal);
+ Assert.Contains("mixed-plan safety", source, StringComparison.OrdinalIgnoreCase);
}
[Fact]
- public void StaticFailureRecovery_AlsoHonorsAndCanOpenDynamicCircuitBreaker()
+ public void StaticFailure_IsBaselineIsolatedAndCannotOpenOrUseDynamicCircuit()
{
var source = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs");
- var guard = source.IndexOf(
- "DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId",
- StringComparison.Ordinal);
- var dynamicAttempt = source.IndexOf(
- "StartPersistentReportMonitorWithAttemptEvidenceAsync",
- StringComparison.Ordinal);
- var openCircuit = source.IndexOf(
- "DynamicWriteCircuitByDevice[appPlan.RelayId] = failure",
- StringComparison.Ordinal);
-
- Assert.True(guard >= 0, "P4 static-recovery path must check the P6 circuit before any dynamic write.");
- Assert.True(dynamicAttempt > guard, "Circuit-breaker guard must run before dynamic activation.");
- Assert.True(openCircuit > dynamicAttempt, "A real failed recovery attempt must open the same device circuit.");
- Assert.Contains("FailureReason = \"DynamicWriteCircuitOpen\"", source, StringComparison.Ordinal);
- Assert.Contains("PollingFallbackReason = \"DynamicWriteCircuitOpen\"", source, StringComparison.Ordinal);
+ Assert.Contains("baseline static-failure isolation", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("no dynamic DataSet/RCB write was attempted", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("UsedDynamicDataSet = false", source, StringComparison.Ordinal);
+ Assert.Contains("DynamicAttempted = false", source, StringComparison.Ordinal);
+ Assert.Contains("FailureReason = \"StaticActivationFailed\"", source, StringComparison.Ordinal);
+ Assert.Contains("PollingFallbackReason = \"StaticActivationFailed\"", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DynamicWriteCircuitByDevice[appPlan.RelayId]", source, StringComparison.Ordinal);
}
[Fact]
diff --git a/tests/ARSAS.Tests/VisualSystemP0RegressionTests.cs b/tests/ARSAS.Tests/VisualSystemP0RegressionTests.cs
index 7c8e4cfc..4c13f7ca 100644
--- a/tests/ARSAS.Tests/VisualSystemP0RegressionTests.cs
+++ b/tests/ARSAS.Tests/VisualSystemP0RegressionTests.cs
@@ -1,3 +1,5 @@
+using System.Text.Json;
+
namespace ARSAS.Tests;
public sealed class VisualSystemP0RegressionTests
@@ -81,13 +83,16 @@ public void RuntimeNavigation_UsesStableP0Style_WhileResponsiveFixOwnsFinalWidth
}
[Fact]
- public void P0_KeepsTheReviewedAriecEnginePinUntouched()
+ public void P0_KeepsImmutableAriecEngineLockContractUntouched()
{
var testDirectory = Path.GetDirectoryName(FindRepoFile("MainWindow.xaml"))!;
- var engineLock = File.ReadAllText(Path.Combine(testDirectory, "engines", "ARIEC61850.lock.json"));
+ using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(testDirectory, "engines", "ARIEC61850.lock.json")));
+ var root = document.RootElement;
- Assert.Contains("2a932e183931eb65c775fe01cf8a47bf8a9af458", engineLock, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("\"sourcePullRequest\": 86", engineLock, StringComparison.Ordinal);
+ Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString());
+ Assert.Equal("main", root.GetProperty("ref").GetString());
+ Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty);
+ Assert.True(root.GetProperty("sourcePullRequest").GetInt32() > 0);
}
private static string Slice(string source, string start, string end)
diff --git a/tests/ARSAS.Tests/WorkspaceNoiseP1RegressionTests.cs b/tests/ARSAS.Tests/WorkspaceNoiseP1RegressionTests.cs
index aeaba756..6ffbe1fe 100644
--- a/tests/ARSAS.Tests/WorkspaceNoiseP1RegressionTests.cs
+++ b/tests/ARSAS.Tests/WorkspaceNoiseP1RegressionTests.cs
@@ -1,4 +1,5 @@
using ArIED61850Tester.Models;
+using System.Text.Json;
namespace ARSAS.Tests;
@@ -82,13 +83,17 @@ public void Goose_IdleStateUsesProgressiveDisclosureWithoutRemovingEngineeringCo
}
[Fact]
- public void P1_KeepsReviewedAriecEnginePinUntouched()
+ public void P1_KeepsImmutableAriecEngineLockContractUntouched()
{
var root = Path.GetDirectoryName(FindRepoFile("MainWindow.xaml"))!;
- var engineLock = File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"));
+ var lockPath = Path.Combine(root, "engines", "ARIEC61850.lock.json");
+ using var document = JsonDocument.Parse(File.ReadAllText(lockPath));
+ var rootElement = document.RootElement;
- Assert.Contains("2a932e183931eb65c775fe01cf8a47bf8a9af458", engineLock, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("\"sourcePullRequest\": 86", engineLock, StringComparison.Ordinal);
+ Assert.Equal("masarray/ARIEC61850", rootElement.GetProperty("repository").GetString());
+ Assert.Equal("main", rootElement.GetProperty("ref").GetString());
+ Assert.Matches("^[0-9a-f]{40}$", rootElement.GetProperty("commit").GetString() ?? string.Empty);
+ Assert.True(rootElement.GetProperty("sourcePullRequest").GetInt32() > 0);
}
private static string Slice(string source, string start, string end)