From e6b908f0d7ff853c86c3b2ae117575ee868bb46b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 13:20:25 +0700 Subject: [PATCH 01/10] P6.1 stop static failures from triggering dynamic writes --- ...NativeIec61850Client.HybridReporting.P4.cs | 191 +++--------------- 1 file changed, 24 insertions(+), 167 deletions(-) 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) From d893ca7d485d0a53aab226be63e25afbc24967d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 13:22:42 +0700 Subject: [PATCH 02/10] P6.1 lock static failure isolation --- ...idReportDynamicAttemptP4RegressionTests.cs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) 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); } From cefd991cc06a987390934c30560bc8f72b43d18b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 13:22:59 +0700 Subject: [PATCH 03/10] P6.1 keep static failures side effect free --- .../P6FieldStabilityRegressionTests.cs | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs index 1f857817..e095d1b5 100644 --- a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs @@ -76,25 +76,20 @@ public void FailedRealDynamicAttempt_OpensProcessLifetimeCircuitBreaker() } [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] From bb2a586019a881081c616e65ebcf550b33cbb559 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 13:24:34 +0700 Subject: [PATCH 04/10] Pin ARSAS to ARIEC P6.1 static-preservation engine --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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." } From 9882adb1e1427e3652c3835a2b97d28baf2c820c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 13:24:57 +0700 Subject: [PATCH 05/10] P6.1 update engine provenance regression --- ...HybridReportAssociationCapabilityP3RegressionTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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) From b3eb97f93497d0cffcb58de8037200c6a64ca9b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 13:28:06 +0700 Subject: [PATCH 06/10] Keep workspace regression independent of reviewed engine revision --- .../ARSAS.Tests/WorkspaceNoiseP1RegressionTests.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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) From 1f2fb68afbca6d4ed8fbc59b79416dc77a31b50f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 13:28:31 +0700 Subject: [PATCH 07/10] Keep visual regression independent of reviewed engine revision --- tests/ARSAS.Tests/VisualSystemP0RegressionTests.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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) From 12cdcc9beba9d02a5c10d501c259199ebb097ac2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 13:28:54 +0700 Subject: [PATCH 08/10] Update DataSet provenance guard for P6.1 engine pin --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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] From 9d8b2d359cfb9536308bc2be07000c0ab818234e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 14:10:52 +0700 Subject: [PATCH 09/10] fix(p6.1): isolate static RCB from dynamic association-abort probation --- .../NativeIec61850Client.HybridReporting.cs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) 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) From 01b950ccf06a35dab7bd145c58cc4ebd58104b7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 18 Aug 2026 14:13:00 +0700 Subject: [PATCH 10/10] test(p6.1): lock mixed dynamic probation before static arming --- .../P6FieldStabilityRegressionTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs index e095d1b5..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,6 +127,7 @@ 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]