From 495d533ce90a3f0cd1d04dd81073495a3dc81a69 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 11:29:43 +0700 Subject: [PATCH 01/77] G2.6 enable guarded static-to-dynamic RCB recovery --- ...NativeIec61850Client.HybridReporting.P4.cs | 149 +++++++++++++++--- 1 file changed, 130 insertions(+), 19 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.P4.cs b/Services/NativeIec61850Client.HybridReporting.P4.cs index 09a3da8a..2fdc1977 100644 --- a/Services/NativeIec61850Client.HybridReporting.P4.cs +++ b/Services/NativeIec61850Client.HybridReporting.P4.cs @@ -7,41 +7,152 @@ namespace ArIED61850Tester.Services; public sealed partial class NativeIec61850Client { /// - /// P6.1 baseline-safety compatibility hook. + /// G2.6 Smart Auto recovery for a static report segment that cannot be activated. /// - /// 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. + /// Recovery is deliberately narrower than the original P4 experiment: + /// - the failed static RCB is excluded from the recovery availability evidence; + /// - static RCBs are disabled in the recovery planner, so only an alternate dynamic + /// BRCB/URCB can be selected; + /// - a post-mutation static failure may recover only after rollback/cleanup is proven; + /// - ARIEC capability + exact availability evidence remains authoritative; + /// - StartHybridReportMonitorAsync performs another fresh discovery/revalidation before + /// any dynamic DataSet/RCB write and retains the process-lifetime dynamic-write circuit; + /// - the original PlanId is preserved so runtime routing/coverage ownership does not fork. /// - /// The method name is retained temporarily so existing call-sites stay source-compatible; - /// its behavior is deliberately fail-closed and side-effect free. + /// If any gate is not satisfied, bounded MMS polling remains the final fallback. /// - private Task TryStartDynamicRecoveryAfterStaticFailureP4Async( + private async Task TryStartDynamicRecoveryAfterStaticFailureP4Async( ReportControlPlan appPlan, AuthoritativeHybridSubscription authoritative, ArMms.MmsDiscoveryResult discovery, ArMms.MmsRcbAvailabilityResult freshAvailability, string staticFailure, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool staticCleanupProven = true) { - _ = authoritative; - _ = discovery; - _ = freshAvailability; - _ = cancellationToken; + ArgumentNullException.ThrowIfNull(appPlan); + ArgumentNullException.ThrowIfNull(authoritative); + ArgumentNullException.ThrowIfNull(discovery); + ArgumentNullException.ThrowIfNull(freshAvailability); + cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(new NativeReportMonitorStartResult + NativeReportMonitorStartResult Fallback(string reason, string detail) => new() { IsSuccess = false, PlanId = appPlan.PlanId, - 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.", + Message = $"{staticFailure} Smart Auto dynamic recovery withheld: {detail} Bounded MMS polling remains active for this affected signal set.", UsedDynamicDataSet = false, DynamicAttempted = false, DynamicAttemptState = "Skipped", - FailureReason = "StaticActivationFailed", - PollingFallbackReason = "StaticActivationFailed" - }); + FailureReason = reason, + PollingFallbackReason = reason, + Warnings = freshAvailability.Warnings + }; + + if (!IsStaticHybridKind(authoritative.Kind)) + return Fallback("StaticRecoveryNotApplicable", "the failed authoritative segment is not static."); + + if (!staticCleanupProven) + { + return Fallback( + "StaticCleanupUnproven", + "the failed static activation mutated report state and rollback/cleanup was not proven; a second RCB mutation is forbidden on this association."); + } + + if (!_session.IsMmsInitiated) + return Fallback("TransportUnavailable", $"the MMS association is no longer initiated ({_session.State})."); + + if (!authoritative.Options.AllowDynamicBrcb && !authoritative.Options.AllowDynamicUrcb) + return Fallback("DynamicRecoveryDisabled", "dynamic BRCB/URCB acquisition is disabled by the current Smart Auto policy."); + + if (!string.IsNullOrWhiteSpace(appPlan.RelayId) && + DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId, out var circuitReason)) + { + return Fallback( + "DynamicWriteCircuitOpen", + $"the device dynamic-write circuit is already open after real field failure evidence ({circuitReason})."); + } + + // Never turn the RCB that just failed static activation into a dynamic target. + // Recovery must use a distinct, freshly classified RCB so a bad/busy/static object + // cannot be immediately mutated under a different acquisition label. + var alternateSnapshots = freshAvailability.ReportControls + .Where(snapshot => !SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)) + .ToArray(); + if (alternateSnapshots.Length == 0) + { + return Fallback( + "NoAlternateRcbEvidence", + $"no alternate RCB has fresh availability evidence after excluding {authoritative.ReportControlReference}."); + } + + var alternateAvailability = new ArMms.MmsRcbAvailabilityResult + { + CheckedAtUtc = freshAvailability.CheckedAtUtc, + ReportControls = alternateSnapshots, + Warnings = freshAvailability.Warnings + }; + + var recoveryOptions = new ArMms.MmsHybridReportAcquisitionOptions + { + AllowStaticBrcb = false, + AllowStaticUrcb = false, + AllowDynamicBrcb = authoritative.Options.AllowDynamicBrcb, + AllowDynamicUrcb = authoritative.Options.AllowDynamicUrcb, + AllowCallerOwnedReports = false, + AllowPollingFallback = true, + RequireExactAvailabilityEvidence = true + }; + + var recoveryCapability = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + authoritative.Catalog, + authoritative.Signals, + discovery.ReportInventory, + alternateAvailability, + discovery.IedDirectory, + _session.LastNegotiatedCapabilities, + recoveryOptions); + + var dynamicSegment = recoveryCapability.AcquisitionPlan.Segments.FirstOrDefault(segment => + segment.IsReportBacked && + segment.ReportPlan is not null && + segment.Kind is ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ArMms.MmsHybridAcquisitionKind.DynamicUrcb); + + if (dynamicSegment?.ReportPlan is null) + { + var blocker = recoveryCapability.Blockers.FirstOrDefault(); + var warning = recoveryCapability.Warnings.FirstOrDefault(); + var detail = !string.IsNullOrWhiteSpace(blocker) + ? blocker + : !string.IsNullOrWhiteSpace(warning) + ? warning + : "ARIEC found no exact alternate dynamic report segment for the affected signals."; + return Fallback("NoDynamicRecoverySegment", detail); + } + + // Preserve the runtime plan identity while replacing only its acquisition target. + // Runtime dictionaries, report slice routing and PointPlanIds therefore continue to + // refer to one plan even though Smart Auto escalated static -> dynamic. + appPlan.ReportControlReference = dynamicSegment.ReportControlReference; + appPlan.DataSetReference = dynamicSegment.DataSetReference; + appPlan.Mode = $"ARIEC Hybrid • {dynamicSegment.Kind} • static recovery"; + appPlan.AllowDynamicDataSetWrites = true; + appPlan.Buffered = dynamicSegment.Kind == ArMms.MmsHybridAcquisitionKind.DynamicBrcb; + appPlan.Status = $"{dynamicSegment.Kind} recovery planned"; + appPlan.IsEngineAuthoritative = true; + appPlan.EngineAcquisitionKind = dynamicSegment.Kind.ToString(); + + _authoritativeHybridSubscriptions[appPlan.PlanId] = new AuthoritativeHybridSubscription( + dynamicSegment.Kind, + dynamicSegment.ReportControlReference, + authoritative.Catalog, + dynamicSegment.Signals.ToArray(), + recoveryOptions); + + // This recursive entry is safe: the authoritative subscription is now dynamic, so + // any subsequent failure cannot re-enter static recovery. It also gives the dynamic + // target a fresh discovery + exact availability revalidation immediately before write. + return await StartHybridReportMonitorAsync(appPlan, cancellationToken).ConfigureAwait(false); } private static bool IsStaticHybridKind(ArMms.MmsHybridAcquisitionKind kind) From 0f2bf447c53af672a406d3d1b9e66b251ee38417 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 11:31:32 +0700 Subject: [PATCH 02/77] G2.6 fail closed when static rollback evidence is unavailable --- .../NativeIec61850Client.HybridReporting.P4.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.P4.cs b/Services/NativeIec61850Client.HybridReporting.P4.cs index 2fdc1977..b0012575 100644 --- a/Services/NativeIec61850Client.HybridReporting.P4.cs +++ b/Services/NativeIec61850Client.HybridReporting.P4.cs @@ -28,7 +28,7 @@ private async Task TryStartDynamicRecoveryAfterS ArMms.MmsRcbAvailabilityResult freshAvailability, string staticFailure, CancellationToken cancellationToken, - bool staticCleanupProven = true) + bool staticCleanupProven = false) { ArgumentNullException.ThrowIfNull(appPlan); ArgumentNullException.ThrowIfNull(authoritative); @@ -52,11 +52,20 @@ private async Task TryStartDynamicRecoveryAfterS if (!IsStaticHybridKind(authoritative.Kind)) return Fallback("StaticRecoveryNotApplicable", "the failed authoritative segment is not static."); - if (!staticCleanupProven) + // The current StartHybridReportMonitorAsync call sites distinguish pre-write + // revalidation failures from the one post-write activation failure through this + // stable diagnostic prefix. Pre-write failures have nothing to roll back. A real + // activation failure, however, MUST carry explicit CleanupSucceeded evidence before + // this method is allowed to mutate an alternate RCB. Until the caller supplies that + // evidence, fail closed rather than assuming cleanup from a return code/message. + var staticMutationWasAttempted = staticFailure.Contains( + "hybrid report activation failed", + StringComparison.OrdinalIgnoreCase); + if (staticMutationWasAttempted && !staticCleanupProven) { return Fallback( "StaticCleanupUnproven", - "the failed static activation mutated report state and rollback/cleanup was not proven; a second RCB mutation is forbidden on this association."); + "the failed static activation reached the mutation path and rollback/cleanup was not explicitly proven; a second RCB mutation is forbidden on this association."); } if (!_session.IsMmsInitiated) From bf8147631e317582a60224a52c5bd69fb7892a7b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 11:32:33 +0700 Subject: [PATCH 03/77] G2.6 pass proven static cleanup into dynamic recovery --- Services/NativeIec61850Client.HybridReporting.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index 99b3b0ba..746b473d 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -568,7 +568,14 @@ segment.ReportPlan is not null && { var message = $"ARIEC hybrid report activation failed for {plan.DisplayReference}: {start.Message}"; if (!isDynamic) - return await TryStartDynamicRecoveryAfterStaticFailureP4Async(plan, authoritative, discovery, freshAvailability, message, cancellationToken).ConfigureAwait(false); + return await TryStartDynamicRecoveryAfterStaticFailureP4Async( + plan, + authoritative, + discovery, + freshAvailability, + message, + cancellationToken, + staticCleanupProven: attempt.CleanupSucceeded).ConfigureAwait(false); if (attempt.DynamicAttempted && !string.IsNullOrWhiteSpace(plan.RelayId)) { From 1fdae96cfe72b3d1593867d7aa8b93ab6e4cbd3d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 11:32:53 +0700 Subject: [PATCH 04/77] G2.6 regress guarded static-to-dynamic recovery --- ...idReportDynamicAttemptP4RegressionTests.cs | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs b/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs index 037ef597..b4db0e73 100644 --- a/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs +++ b/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs @@ -19,28 +19,54 @@ public void Planning_ProjectsEngineAttemptEvidenceInsteadOfSilentPolling() } [Fact] - public void StaticFailure_IsIsolatedAndNeverStartsDynamicMutation() + public void StaticFailure_GetsGuardedAlternateDynamicRecoveryBeforePolling() { 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); - // 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); + // G2.6 may recover a failed static segment, but only through the ARIEC planner and + // a different RCB with fresh availability evidence. P4 never writes an RCB directly. + Assert.Contains("alternateSnapshots", recovery, StringComparison.Ordinal); + Assert.Contains("!SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)", recovery, StringComparison.Ordinal); + Assert.Contains("AllowStaticBrcb = false", recovery, StringComparison.Ordinal); + Assert.Contains("AllowStaticUrcb = false", recovery, StringComparison.Ordinal); + Assert.Contains("AllowDynamicBrcb = authoritative.Options.AllowDynamicBrcb", recovery, StringComparison.Ordinal); + Assert.Contains("AllowDynamicUrcb = authoritative.Options.AllowDynamicUrcb", recovery, StringComparison.Ordinal); + Assert.Contains("RequireExactAvailabilityEvidence = true", recovery, StringComparison.Ordinal); + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal); + Assert.Contains("_authoritativeHybridSubscriptions[appPlan.PlanId]", recovery, StringComparison.Ordinal); + Assert.Contains("return await StartHybridReportMonitorAsync(appPlan", recovery, StringComparison.Ordinal); Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); - Assert.DoesNotContain("DynamicWriteCircuitByDevice[appPlan.RelayId]", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", recovery, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StaticPostMutationRecovery_RequiresProvenCleanup() + { + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + Assert.Contains("bool staticCleanupProven = false", recovery, StringComparison.Ordinal); + Assert.Contains("staticMutationWasAttempted", recovery, StringComparison.Ordinal); + Assert.Contains("StaticCleanupUnproven", recovery, StringComparison.Ordinal); + Assert.Contains("a second RCB mutation is forbidden", recovery, StringComparison.OrdinalIgnoreCase); + Assert.Contains("staticCleanupProven: attempt.CleanupSucceeded", bridge, StringComparison.Ordinal); + } + + [Fact] + public void DynamicRecovery_RetainsCircuitBreakerAndPlanIdentity() + { + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + Assert.Contains("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", recovery, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitOpen", recovery, StringComparison.Ordinal); + Assert.Contains("appPlan.EngineAcquisitionKind = dynamicSegment.Kind.ToString()", recovery, StringComparison.Ordinal); + Assert.Contains("_authoritativeHybridSubscriptions[appPlan.PlanId]", recovery, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice[plan.RelayId] = reason", bridge, StringComparison.Ordinal); } [Fact] From d3d96e3b616c92018cbe0b1867f7e98566fd209a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 11:33:12 +0700 Subject: [PATCH 05/77] G2.6 preserve field safety around smart dynamic recovery --- .../P6FieldStabilityRegressionTests.cs | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs index f9e22ee2..7da3ef80 100644 --- a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs @@ -131,20 +131,27 @@ public void FailedRealDynamicAttempt_OpensProcessLifetimeCircuitBreaker() } [Fact] - public void StaticFailure_IsBaselineIsolatedAndCannotOpenOrUseDynamicCircuit() + public void StaticFailure_RecoveryPreservesP6FieldSafety() { - var source = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); - - 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); + var bridge = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + // Recovery cannot reuse the failed static RCB and cannot write directly from the + // compatibility layer. ARIEC must plan an alternate dynamic target from fresh data. + Assert.Contains("alternateSnapshots", recovery, StringComparison.Ordinal); + Assert.Contains("!SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)", recovery, StringComparison.Ordinal); + Assert.Contains("AllowStaticBrcb = false", recovery, StringComparison.Ordinal); + Assert.Contains("AllowStaticUrcb = false", recovery, StringComparison.Ordinal); + Assert.Contains("RequireExactAvailabilityEvidence = true", recovery, StringComparison.Ordinal); + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", recovery, StringComparison.OrdinalIgnoreCase); + + // A static activation that already touched RCB state needs positive rollback evidence + // before Smart Auto is allowed to attempt the alternate dynamic RCB. + Assert.Contains("StaticCleanupUnproven", recovery, StringComparison.Ordinal); + Assert.Contains("staticCleanupProven: attempt.CleanupSucceeded", bridge, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", recovery, StringComparison.Ordinal); } [Fact] From bbdd86483461fad5e8f514c0348e09e79d0d72fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 11:33:30 +0700 Subject: [PATCH 06/77] G2.6 update P6.2B recovery safety regression --- .../P62BFieldStabilityRegressionTests.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs index 3910c301..39d4fe97 100644 --- a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs @@ -88,13 +88,24 @@ public void AmbiguousStructuredStaticValue_CannotOverwriteScalarProcessState() } [Fact] - public void P61StaticFailureIsolation_RemainsIntact() + public void G26SmartRecovery_DoesNotRegressP62BMutationQuarantine() { - var source = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); + var bridge = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); - Assert.Contains("no dynamic DataSet/RCB write was attempted", source, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", source, StringComparison.Ordinal); + // P4 is still not a wire writer. It can only ask the capability-aware planner for + // an alternate target, replace the authoritative plan, then re-enter the normal + // StartHybrid path where fresh availability and the dynamic circuit are enforced. + Assert.DoesNotContain("DefineNamedVariableList", recovery, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal); + Assert.Contains("StartHybridReportMonitorAsync(appPlan", recovery, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", recovery, StringComparison.Ordinal); + + // Post-mutation static recovery is gated by the engine's actual rollback result. + Assert.Contains("StaticCleanupUnproven", recovery, StringComparison.Ordinal); + Assert.Contains("staticCleanupProven: attempt.CleanupSucceeded", bridge, StringComparison.Ordinal); + Assert.Contains("!SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)", recovery, StringComparison.Ordinal); } private static string ReadRepoFile(string relativePath) From 4521dc39000f35ecc2c788ff87c9500351afb5e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:23:05 +0700 Subject: [PATCH 07/77] P1 pin ARIEC G2.6 production consumer --- engines/ARIEC61850.lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 268797b4..88fef07b 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "repository": "masarray/ARIEC61850", - "ref": "main", - "commit": "26c85400a4da230c4429e6302847f230385b6687", - "sourcePullRequest": 95, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase." + "ref": "g2.6-production-dynamic-consumer", + "commit": "a2b2265af54afd87b98aadcf63e302725c97d347", + "sourcePullRequest": 97, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 closes the engine-side P6.2-B production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only the exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. ARSAS P1 pins this immutable engine commit for deterministic A3 development; the current field profile remains InformationReportProven and production automatic dynamic reporting remains OFF until later shadow/regression gates explicitly mark it ProductionEligible." } From 040a7d95c3f008062345ebba63337554087a44c0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:24:44 +0700 Subject: [PATCH 08/77] P1 add deterministic command-bound A3 commissioning --- ...mandBoundDataChangeCommissioningService.cs | 633 ++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 Services/DynamicReportCommandBoundDataChangeCommissioningService.cs diff --git a/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs b/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs new file mode 100644 index 00000000..b20c9a08 --- /dev/null +++ b/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs @@ -0,0 +1,633 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportCommandBoundA3Transition +{ + public int Index { get; init; } + public string MemberReference { get; init; } = string.Empty; + public string PointReference { get; init; } = string.Empty; + public string BeforeValue { get; init; } = string.Empty; + public string AfterValue { get; init; } = string.Empty; + public DateTimeOffset ObservedAtUtc { get; init; } +} + +internal sealed class DynamicReportCommandBoundA3WitnessResult +{ + public bool BaselineCaptured { get; init; } + public bool CommandCaptured { get; init; } + public bool CommandBoundTransitionProven { get; init; } + public bool AssociationHealthy { get; init; } + public string CommandSignalReference { get; init; } = string.Empty; + public string ControlStatusReference { get; init; } = string.Empty; + public string RequestedValue { get; init; } = string.Empty; + public string CommandSource { get; init; } = string.Empty; + public DateTimeOffset? CommandObservedAtUtc { get; init; } + public int SampleCycles { get; init; } + public int ReadFailures { get; init; } + public IReadOnlyList Transitions { get; init; } = Array.Empty(); + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); + public string Summary { get; init; } = string.Empty; +} + +internal sealed class DynamicReportCommandBoundA3CommissioningResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public bool CommandBoundReportCorrelationProven { get; init; } + public IReadOnlyList CorrelatedIndexes { get; init; } = Array.Empty(); + public IReadOnlyList CorrelatedMemberReferences { get; init; } = Array.Empty(); + public DynamicReportSpontaneousDataChangeCommissioningResult CoreResult { get; init; } = new(); + public DynamicReportCommandBoundA3WitnessResult Witness { get; init; } = new(); + public string Summary { get; init; } = string.Empty; + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +internal sealed record DynamicReportCommandBoundA3EligibleTarget( + SignalDefinition Signal, + ArMms.MmsFcResolvedPoint ExactStatusPoint, + IReadOnlyList QualifiedFocusPoints, + IReadOnlyList QualifiedIndexes); + +/// +/// G2.6-P1 deterministic A3 wrapper. +/// +/// The reporting path remains the existing G2.5-A one-URCB dchg-only / NO-GI transaction. +/// A second isolated MMS association is read-only and is used only to prove that the exact +/// pre-existing ARSAS control command caused a transition on a member that belongs to the +/// exact G2.4-proven DataSet envelope. The command itself remains owned by the existing +/// Iec61850MonitorRuntime control path; this service only observes its already-existing +/// "Control execution requested:" Diagnostic entry and never calls ExecuteControlAsync. +/// +/// PASS therefore requires all of the following in one bounded armed window: +/// - exact InformationReportProven identity/profile and G2.4 RCB/member sequence; +/// - at least one ARSAS control object whose A2.1 focus chain intersects that exact sequence; +/// - core dchg-only activation/report/cleanup success with GI disabled; +/// - one exact runtime-observed ARSAS command after the witness baseline is ready; +/// - a post-command MMS transition on a qualified command-focus member; +/// - the dchg InformationReport includes the same DataSet index. +/// +/// This service never saves or advances the qualification profile and cannot set +/// ProductionEligible. Production automatic dynamic reporting remains a later gate. +/// +internal sealed class DynamicReportCommandBoundDataChangeCommissioningService +{ + internal const string ReadyMarker = "G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND"; + internal const string CommandCapturedMarker = "G2.6-P1 A3 COMMAND CAPTURED"; + internal const string TransitionMarker = "G2.6-P1 A3 COMMAND-BOUND TRANSITION"; + internal static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10); + internal static readonly TimeSpan CommandWaitWindow = TimeSpan.FromSeconds(45); + internal static readonly TimeSpan CommandTransitionWindow = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan PostTransitionSettleWindow = TimeSpan.FromMilliseconds(350); + internal static readonly TimeSpan InterCycleDelay = TimeSpan.FromMilliseconds(1); + + private readonly DynamicReportQualificationProfileStore _profileStore; + + public DynamicReportCommandBoundDataChangeCommissioningService( + DynamicReportQualificationProfileStore? profileStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + } + + public async Task RunAsync( + Iec61850MonitorRuntime runtime, + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var evidence = new List + { + "G2.6-P1 A3 contract: exact existing ARSAS command -> read-only command-bound qualified-member transition -> dchg InformationReport on the same DataSet index -> mandatory G2.5-A cleanup.", + "G2.6-P1 A3 control safety: this service never calls ExecuteControlAsync and never writes SBO/SBOw/Operate/Cancel; command authority remains the existing Iec61850MonitorRuntime path.", + "G2.6-P1 A3 report safety: core path is strict dchg-only with GI=false, integrity=false, qchg=false and dupd=false. The read-only witness performs no RCB/DataSet operation.", + "G2.6-P1 A3 profile safety: persisted InformationReportProven evidence is read-only; this service cannot save, advance or mark ProductionEligible." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("A3 identity preflight failed: " + ex.Message, evidence); + } + + var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.6-P1 A3 profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + if (!loaded.IsValid || loaded.Profile is null || + loaded.Profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven || + loaded.Profile.RcbActivationProof?.IsSuccess != true || + loaded.Profile.InformationReportProof?.IsSuccess != true) + { + return Blocked("A3 requires the exact identity-compatible InformationReportProven G2.4 profile.", evidence); + } + + var profile = loaded.Profile; + var qualifiedReferences = profile.RcbActivationProof.MemberReferences.ToArray(); + if (qualifiedReferences.Length == 0) + return Blocked("A3 profile contains no exact G2.4 member sequence.", evidence); + + var commandSignals = fullModelSignals + .Where(signal => signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + .Distinct() + .ToArray(); + if (commandSignals.Length == 0) + return Blocked("No live control object exposes ControlStatusReference; A3 will not guess command/status correlation.", evidence); + if (commandSignals.Any(signal => signal.ControlCommandBusy)) + return Blocked("A control command is already in progress. A3 must be armed before the one test command starts.", evidence); + + await using var witnessSession = new ArMms.MmsClientSession(); + ArMms.MmsDiscoveryResult witnessDiscovery; + try + { + await witnessSession.ConnectAsync( + device.IpAddress, + device.Port, + AuxiliaryAssociationTimeout, + cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.6-P1 A3 witness association ready: state={witnessSession.State}; localTcpAddress={TextOrDash(witnessSession.LocalTcpAddress)}; READ-ONLY=true"); + + witnessDiscovery = await witnessSession.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add("G2.6-P1 A3 witness discovery: " + witnessDiscovery.Summary); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"G2.6-P1 A3 witness preflight exception: {ex.GetType().Name}: {ex.Message}"); + return Blocked("A3 could not establish its isolated read-only MMS witness association.", evidence); + } + + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + witnessDiscovery.IedDirectory, + qualifiedReferences, + out var exactQualifiedPoints, + out var memberReason)) + { + evidence.Add("G2.6-P1 A3 exact member resolution failed: " + memberReason); + return Blocked("The exact G2.4-proven member sequence no longer resolves on the live IED.", evidence); + } + + var eligibleTargets = BuildEligibleCommandTargets( + witnessDiscovery.IedDirectory, + commandSignals, + qualifiedReferences, + evidence); + if (eligibleTargets.Count == 0) + { + evidence.Add("G2.6-P1 A3 preflight: no command focus chain intersects the exact G2.4 DataSet envelope. No RCB mutation was attempted."); + return Blocked( + "No current ARSAS command has a command-bound A2.1 status candidate inside the exact G2.4-proven member envelope. Re-qualify an envelope containing CSWI/XCBR status before A3.", + evidence); + } + + evidence.Add("G2.6-P1 A3 eligible commands: " + string.Join(" | ", eligibleTargets.Select(target => + $"{target.Signal.ObjectReference} -> status={target.ExactStatusPoint.UserReference}; qualifiedIndexes=[{string.Join(",", target.QualifiedIndexes)}]"))); + + var armed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var commandCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var witnessReady = 0; + + void RuntimeDiagnosticHandler(DiagnosticEntry entry) + { + if (Volatile.Read(ref witnessReady) != 1) + return; + if (!DynamicReportCommandBoundStimulusWitnessServiceV3.TryBuildRuntimeIntent( + entry, + device, + fullModelSignals, + out var intent) || intent is null) + return; + if (!eligibleTargets.Any(target => ReferenceEquals(target.Signal, intent.Signal) || + SameReference(target.Signal.ObjectReference, intent.Signal.ObjectReference))) + return; + commandCapture.TrySetResult(intent); + } + + runtime.Diagnostic += RuntimeDiagnosticHandler; + using var witnessCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var relay = new RelayProgress(text => + { + if (text.Contains(DynamicReportStimulusWitnessCommissioningService.ArmedMarker, StringComparison.OrdinalIgnoreCase)) + { + armed.TrySetResult(true); + progress?.Report("G2.6-P1 A3: dchg-only report path is ARMED with NO GI; capturing the final pre-command read-only baseline…"); + return; + } + progress?.Report(text); + }); + + var witnessTask = RunCommandWitnessAsync( + witnessSession, + exactQualifiedPoints, + qualifiedReferences, + eligibleTargets, + armed.Task, + commandCapture.Task, + ready => Volatile.Write(ref witnessReady, ready ? 1 : 0), + progress, + witnessCancellation.Token); + + DynamicReportSpontaneousDataChangeCommissioningResult coreResult; + try + { + var coreService = new DynamicReportSpontaneousDataChangeCommissioningService(_profileStore); + coreResult = await coreService.RunAsync( + device, + fullModelSignals, + relay, + cancellationToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref witnessReady, 0); + runtime.Diagnostic -= RuntimeDiagnosticHandler; + if (!armed.Task.IsCompleted) + witnessCancellation.Cancel(); + } + + DynamicReportCommandBoundA3WitnessResult witnessResult; + try + { + witnessResult = await witnessTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + witnessResult = new DynamicReportCommandBoundA3WitnessResult + { + Summary = "A3 command witness was cancelled because the core report transaction never reached ARMED.", + EvidenceLines = ["G2.6-P1 A3 witness: core path did not reach ARMED; no command-bound conclusion is possible."] + }; + } + + evidence.AddRange(coreResult.EvidenceLines.Select(line => "CORE/" + line)); + evidence.AddRange(witnessResult.EvidenceLines.Select(line => "WITNESS/" + line)); + + var changedIndexes = witnessResult.Transitions + .Select(transition => transition.Index) + .Distinct() + .OrderBy(index => index) + .ToArray(); + var correlatedIndexes = CorrelateIndexes(coreResult.IncludedIndexes, changedIndexes); + var correlatedMembers = correlatedIndexes + .Where(index => index >= 0 && index < qualifiedReferences.Length) + .Select(index => qualifiedReferences[index]) + .ToArray(); + + var correlation = coreResult.SpontaneousDataChangeProven && + witnessResult.CommandCaptured && + witnessResult.CommandBoundTransitionProven && + correlatedIndexes.Length > 0; + var success = coreResult.IsSuccess && correlation; + + string diagnosis; + if (success) + { + diagnosis = $"G2.6-P1 A3 PASS: exact ARSAS command {witnessResult.CommandSignalReference} produced a command-bound transition and the dchg InformationReport included the same exact DataSet index(es) [{string.Join(",", correlatedIndexes)}]; monitor/proof-field/fresh-association cleanup all passed."; + } + else if (!coreResult.ActivationProven) + { + diagnosis = "A3 did not reach a proven dchg-only ARMED state; command/report correlation is inconclusive."; + } + else if (!witnessResult.CommandCaptured) + { + diagnosis = "A3 report path armed, but no eligible existing ARSAS command was captured after the read-only baseline became ready."; + } + else if (!witnessResult.CommandBoundTransitionProven) + { + diagnosis = "A3 captured the exact ARSAS command, but no qualified command-focus member changed in the bounded high-speed witness window."; + } + else if (!coreResult.SpontaneousDataChangeProven) + { + diagnosis = $"A3 captured the command and witnessed qualified DataSet index(es) [{string.Join(",", changedIndexes)}] change, but no valid dchg InformationReport arrived. This isolates the remaining fault to dchg/report emission or receive-path evidence."; + } + else if (correlatedIndexes.Length == 0) + { + diagnosis = $"A3 received a valid dchg report, but its included indexes [{string.Join(",", coreResult.IncludedIndexes)}] did not match command-bound changed indexes [{string.Join(",", changedIndexes)}]."; + } + else + { + diagnosis = "A3 command/report correlation did not close every required gate."; + } + + evidence.Add($"G2.6-P1 A3 combined: coreSuccess={coreResult.IsSuccess}; activation={coreResult.ActivationProven}; dchg={coreResult.SpontaneousDataChangeProven}; cleanup={coreResult.MonitorCleanupSucceeded}/{coreResult.ProofFieldRestoreSucceeded}/{coreResult.FreshCleanupClosureSucceeded}; command={witnessResult.CommandCaptured}; commandTransition={witnessResult.CommandBoundTransitionProven}; changed=[{string.Join(",", changedIndexes)}]; reportIncluded=[{string.Join(",", coreResult.IncludedIndexes)}]; correlated=[{string.Join(",", correlatedIndexes)}]; success={success}"); + evidence.Add("G2.6-P1 A3 diagnosis: " + diagnosis); + evidence.Add("G2.6-P1 A3 state: profile remains InformationReportProven. Production automatic dynamic reporting remains OFF; shadow/regression acceptance is still required before ProductionEligible."); + + return new DynamicReportCommandBoundA3CommissioningResult + { + IsSuccess = success, + CommandBoundReportCorrelationProven = correlation, + CorrelatedIndexes = correlatedIndexes, + CorrelatedMemberReferences = correlatedMembers, + CoreResult = coreResult, + Witness = witnessResult, + Summary = diagnosis + " Profile remains InformationReportProven; production dynamic reporting remains OFF.", + EvidenceLines = evidence.ToArray() + }; + } + + internal static IReadOnlyList BuildEligibleCommandTargets( + ArMms.MmsIedModelDirectory directory, + IReadOnlyList commandSignals, + IReadOnlyList qualifiedReferences, + ICollection? evidence = null) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(commandSignals); + ArgumentNullException.ThrowIfNull(qualifiedReferences); + + var qualifiedIndex = qualifiedReferences + .Select((reference, index) => new { Key = NormalizeMms(reference), Index = index }) + .Where(item => item.Key.Length > 0) + .GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Index, StringComparer.OrdinalIgnoreCase); + + var statusPoints = DynamicReportCommandBoundStimulusWitnessService.ResolveCommandStatusPoints( + directory, + commandSignals, + evidence); + var result = new List(); + + foreach (var pair in statusPoints) + { + var qualifiedFocus = DynamicReportCommandBoundStimulusWitnessService + .BuildFocusChain(directory, pair.Value) + .Where(point => qualifiedIndex.ContainsKey(NormalizeMms(point.MmsReference))) + .GroupBy(point => NormalizeMms(point.MmsReference), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray(); + if (qualifiedFocus.Length == 0) + continue; + + var indexes = qualifiedFocus + .Select(point => qualifiedIndex[NormalizeMms(point.MmsReference)]) + .Distinct() + .OrderBy(index => index) + .ToArray(); + result.Add(new DynamicReportCommandBoundA3EligibleTarget(pair.Key, pair.Value, qualifiedFocus, indexes)); + } + + return result + .OrderBy(target => target.Signal.ObjectReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + internal static int[] CorrelateIndexes( + IEnumerable reportIncludedIndexes, + IEnumerable commandBoundChangedIndexes) + { + ArgumentNullException.ThrowIfNull(reportIncludedIndexes); + ArgumentNullException.ThrowIfNull(commandBoundChangedIndexes); + return reportIncludedIndexes + .Intersect(commandBoundChangedIndexes) + .Distinct() + .OrderBy(index => index) + .ToArray(); + } + + private static async Task RunCommandWitnessAsync( + ArMms.MmsClientSession session, + IReadOnlyList exactQualifiedPoints, + IReadOnlyList qualifiedReferences, + IReadOnlyList eligibleTargets, + Task armedSignal, + Task commandSignal, + Action setReady, + IProgress? progress, + CancellationToken cancellationToken) + { + var evidence = new List(); + try + { + await armedSignal.WaitAsync(cancellationToken).ConfigureAwait(false); + var baseline = await ReadValuesAsync(session, exactQualifiedPoints, cancellationToken).ConfigureAwait(false); + if (!baseline.IsSuccess || !session.IsMmsInitiated) + { + evidence.Add("A3 final pre-command baseline failed: " + baseline.Message); + return WitnessFailure("A3 could not capture a complete final pre-command qualified-member baseline.", evidence, session.IsMmsInitiated, baseline.ReadFailures); + } + + evidence.Add("A3 final pre-command baseline: " + string.Join(" | ", qualifiedReferences.Select((reference, index) => $"[{index}] {reference}={baseline.Values[index]}"))); + evidence.Add("A3 eligible command objects: " + string.Join(" | ", eligibleTargets.Select(target => target.Signal.ObjectReference))); + setReady(true); + progress?.Report($"{ReadyMarker} — issue exactly ONE already-proven safe OPEN/CLOSE using normal ARSAS control. Eligible object(s): {string.Join(", ", eligibleTargets.Select(target => target.Signal.ObjectReference))}. Do not issue an external/manual stimulus."); + + DynamicReportObservedCommandIntent command; + try + { + command = await commandSignal.WaitAsync(CommandWaitWindow, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + evidence.Add("A3 command wait timed out after the witness baseline was ready."); + return WitnessFailure("No eligible existing ARSAS command was captured in the bounded A3 command window.", evidence, session.IsMmsInitiated, baseline: baseline.Values); + } + finally + { + setReady(false); + } + + var target = eligibleTargets.First(item => ReferenceEquals(item.Signal, command.Signal) || + SameReference(item.Signal.ObjectReference, command.Signal.ObjectReference)); + evidence.Add($"{CommandCapturedMarker}: object={command.Signal.ObjectReference}; requested={command.RequestedValue}; status={command.Signal.ControlStatusReference}; source={command.Source}; at={command.ObservedAtUtc:O}; qualifiedFocus=[{string.Join(",", target.QualifiedIndexes)}]"); + progress?.Report($"{CommandCapturedMarker} — {command.Signal.ObjectReference} requested={command.RequestedValue}. High-speed read-only sampling is active; do NOT issue another command."); + + var focus = target.QualifiedFocusPoints + .Select(point => new + { + Point = point, + Index = Array.FindIndex(qualifiedReferences.ToArray(), reference => SameMms(reference, point.MmsReference)) + }) + .Where(item => item.Index >= 0) + .ToArray(); + if (focus.Length == 0) + return WitnessFailure("Captured command lost its qualified A2.1 focus intersection before sampling.", evidence, session.IsMmsInitiated, baseline: baseline.Values, command: command); + + var deadline = DateTimeOffset.UtcNow + CommandTransitionWindow; + DateTimeOffset? settleDeadline = null; + var cycles = 0; + var failures = baseline.ReadFailures; + var transitions = new List(); + var currentValues = baseline.Values.ToArray(); + + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + cycles++; + foreach (var item in focus) + { + var read = await session.ReadSingleVariableAsync(item.Point.ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null) + { + failures++; + continue; + } + + var current = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value)); + if (string.Equals(currentValues[item.Index], current, StringComparison.OrdinalIgnoreCase)) + continue; + + var transition = new DynamicReportCommandBoundA3Transition + { + Index = item.Index, + MemberReference = qualifiedReferences[item.Index], + PointReference = item.Point.UserReference, + BeforeValue = currentValues[item.Index], + AfterValue = current, + ObservedAtUtc = DateTimeOffset.UtcNow + }; + currentValues[item.Index] = current; + transitions.Add(transition); + evidence.Add($"{TransitionMarker}: index={transition.Index}; member={transition.MemberReference}; point={transition.PointReference}; before={transition.BeforeValue}; after={transition.AfterValue}; commandAt={command.ObservedAtUtc:O}; observedAt={transition.ObservedAtUtc:O}; deltaMs={(transition.ObservedAtUtc - command.ObservedAtUtc).TotalMilliseconds:0.###}"); + settleDeadline ??= transition.ObservedAtUtc + PostTransitionSettleWindow; + } + + if (!session.IsMmsInitiated) + break; + if (settleDeadline.HasValue && DateTimeOffset.UtcNow >= settleDeadline.Value) + break; + if (InterCycleDelay > TimeSpan.Zero) + await Task.Delay(InterCycleDelay, cancellationToken).ConfigureAwait(false); + } + + var postCommand = transitions + .Where(transition => transition.ObservedAtUtc >= command.ObservedAtUtc) + .ToArray(); + var proven = postCommand.Length > 0 && session.IsMmsInitiated; + evidence.Add($"A3 witness result: commandCaptured=true; transitions={transitions.Count}; postCommand={postCommand.Length}; cycles={cycles}; readFailures={failures}; associationHealthy={session.IsMmsInitiated}; proven={proven}"); + + return new DynamicReportCommandBoundA3WitnessResult + { + BaselineCaptured = true, + CommandCaptured = true, + CommandBoundTransitionProven = proven, + AssociationHealthy = session.IsMmsInitiated, + CommandSignalReference = command.Signal.ObjectReference, + ControlStatusReference = command.Signal.ControlStatusReference, + RequestedValue = command.RequestedValue, + CommandSource = command.Source, + CommandObservedAtUtc = command.ObservedAtUtc, + SampleCycles = cycles, + ReadFailures = failures, + Transitions = postCommand, + EvidenceLines = evidence.ToArray(), + Summary = proven + ? $"A3 witnessed {postCommand.Length} qualified command-bound transition(s) after the exact existing ARSAS command." + : "A3 captured the exact ARSAS command but did not witness a qualified post-command transition." + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"A3 witness exception: {ex.GetType().Name}: {ex.Message}"); + return WitnessFailure("A3 read-only command witness failed before a conclusive transition proof.", evidence, session.IsMmsInitiated); + } + finally + { + setReady(false); + } + } + + private static async Task ReadValuesAsync( + ArMms.MmsClientSession session, + IReadOnlyList points, + CancellationToken cancellationToken) + { + var values = new string[points.Count]; + var failures = 0; + for (var index = 0; index < points.Count; index++) + { + var read = await session.ReadSingleVariableAsync(points[index].ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null) + { + failures++; + values[index] = ""; + continue; + } + values[index] = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value)); + } + + return new ReadBatch + { + IsSuccess = failures == 0, + ReadFailures = failures, + Values = values, + Message = failures == 0 ? "all reads succeeded" : $"{failures} of {points.Count} reads failed" + }; + } + + private static DynamicReportCommandBoundA3WitnessResult WitnessFailure( + string summary, + IReadOnlyList evidence, + bool associationHealthy, + int readFailures = 0, + IReadOnlyList? baseline = null, + DynamicReportObservedCommandIntent? command = null) + => new() + { + BaselineCaptured = baseline is { Count: > 0 }, + CommandCaptured = command is not null, + AssociationHealthy = associationHealthy, + CommandSignalReference = command?.Signal.ObjectReference ?? string.Empty, + ControlStatusReference = command?.Signal.ControlStatusReference ?? string.Empty, + RequestedValue = command?.RequestedValue ?? string.Empty, + CommandSource = command?.Source ?? string.Empty, + CommandObservedAtUtc = command?.ObservedAtUtc, + ReadFailures = readFailures, + Summary = summary, + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportCommandBoundA3CommissioningResult Blocked( + string summary, + IReadOnlyList evidence) + => new() + { + IsBlocked = true, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + EvidenceLines = evidence.ToArray() + }; + + private static string NormalizeMms(string? reference) + => ArMms.MmsFcReferenceNormalizer.NormalizeMmsReference(reference ?? string.Empty); + + private static bool SameMms(string? left, string? right) + => NormalizeMms(left).Equals(NormalizeMms(right), StringComparison.OrdinalIgnoreCase); + + private static bool SameReference(string? left, string? right) + => string.Equals((left ?? string.Empty).Trim().Replace('.', '$'), (right ?? string.Empty).Trim().Replace('.', '$'), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeValue(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private sealed class RelayProgress(Action report) : IProgress + { + public void Report(string value) => report(value); + } + + private sealed class ReadBatch + { + public bool IsSuccess { get; init; } + public int ReadFailures { get; init; } + public IReadOnlyList Values { get; init; } = Array.Empty(); + public string Message { get; init; } = string.Empty; + } +} From 716b53e839983c984a2d8aabd4744da6e25fa92f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:25:11 +0700 Subject: [PATCH 09/77] P1 add deterministic A3 evidence window --- ...ReportQualificationResultWindow.G26P1A3.cs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 DynamicReportQualificationResultWindow.G26P1A3.cs diff --git a/DynamicReportQualificationResultWindow.G26P1A3.cs b/DynamicReportQualificationResultWindow.G26P1A3.cs new file mode 100644 index 00000000..3c4ff462 --- /dev/null +++ b/DynamicReportQualificationResultWindow.G26P1A3.cs @@ -0,0 +1,93 @@ +using System.Text; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +internal partial class DynamicReportQualificationResultWindow +{ + internal DynamicReportQualificationResultWindow(DynamicReportCommandBoundA3CommissioningResult result) + { + ArgumentNullException.ThrowIfNull(result); + InitializeComponent(); + + Title = "G2.6-P1 Deterministic A3 dchg Proof Evidence"; + HeaderText.Text = "G2.6-P1 Deterministic A3 — Command → dchg Report"; + SummaryText.Text = result.Summary; + StateText.Text = result.IsSuccess + ? "A3 Command-Bound dchg Proven" + : result.IsBlocked + ? "Blocked" + : "A3 Not Proven"; + EvidenceTextBox.Text = BuildG26P1A3Evidence(result); + + if (result.IsSuccess) + SetPassBadge(); + } + + private static string BuildG26P1A3Evidence(DynamicReportCommandBoundA3CommissioningResult result) + { + var builder = new StringBuilder(); + builder.AppendLine("ARSAS G2.6-P1 DETERMINISTIC A3 COMMAND-BOUND DCHG EVIDENCE"); + builder.AppendLine(new string('=', 76)); + builder.AppendLine($"Result: {result.Summary}"); + builder.AppendLine($"Blocked: {result.IsBlocked}"); + builder.AppendLine($"A3 success: {result.IsSuccess}"); + builder.AppendLine($"Command/report correlation: {result.CommandBoundReportCorrelationProven}"); + + builder.AppendLine(); + builder.AppendLine("EXISTING ARSAS COMMAND"); + builder.AppendLine($"Captured: {result.Witness.CommandCaptured}"); + builder.AppendLine($"Object: {TextOrDash(result.Witness.CommandSignalReference)}"); + builder.AppendLine($"Control status: {TextOrDash(result.Witness.ControlStatusReference)}"); + builder.AppendLine($"Requested value: {TextOrDash(result.Witness.RequestedValue)}"); + builder.AppendLine($"Source: {TextOrDash(result.Witness.CommandSource)}"); + builder.AppendLine($"Observed at UTC: {result.Witness.CommandObservedAtUtc?.ToString("O") ?? "-"}"); + builder.AppendLine($"Command-bound transition proven: {result.Witness.CommandBoundTransitionProven}"); + builder.AppendLine($"Read-only witness association healthy: {result.Witness.AssociationHealthy}"); + builder.AppendLine($"Witness cycles/read failures: {result.Witness.SampleCycles}/{result.Witness.ReadFailures}"); + + if (result.Witness.Transitions.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("COMMAND-BOUND QUALIFIED-MEMBER TRANSITIONS"); + foreach (var transition in result.Witness.Transitions) + { + builder.AppendLine( + $"[{transition.Index}] {transition.MemberReference} ({transition.PointReference}) {transition.BeforeValue} -> {transition.AfterValue} at {transition.ObservedAtUtc:O}"); + } + } + + builder.AppendLine(); + builder.AppendLine("DCHG INFORMATIONREPORT"); + builder.AppendLine($"Core success: {result.CoreResult.IsSuccess}"); + builder.AppendLine($"Activation proven: {result.CoreResult.ActivationProven}"); + builder.AppendLine($"Spontaneous dchg proven: {result.CoreResult.SpontaneousDataChangeProven}"); + builder.AppendLine($"URCB: {TextOrDash(result.CoreResult.RcbReference)}"); + builder.AppendLine($"Temporary DataSet: {TextOrDash(result.CoreResult.DataSetReference)}"); + builder.AppendLine($"RptID: {TextOrDash(result.CoreResult.ReportId)}"); + builder.AppendLine($"Report included indexes: [{string.Join(",", result.CoreResult.IncludedIndexes)}]"); + builder.AppendLine($"Report reasons: [{string.Join(",", result.CoreResult.Reasons)}]"); + builder.AppendLine($"Correlated command/report indexes: [{string.Join(",", result.CorrelatedIndexes)}]"); + foreach (var member in result.CorrelatedMemberReferences) + builder.AppendLine("- correlated member: " + member); + + builder.AppendLine(); + builder.AppendLine("CLEANUP / RELEASE"); + builder.AppendLine($"Monitor cleanup: {result.CoreResult.MonitorCleanupSucceeded}"); + builder.AppendLine($"TrgOps/OptFlds restore: {result.CoreResult.ProofFieldRestoreSucceeded}"); + builder.AppendLine($"Fresh-association cleanup closure: {result.CoreResult.FreshCleanupClosureSucceeded}"); + builder.AppendLine($"Report association healthy after proof: {result.CoreResult.AssociationHealthyAfterReport}"); + + builder.AppendLine(); + builder.AppendLine("FULL EVIDENCE"); + foreach (var line in result.EvidenceLines) + builder.AppendLine(line); + + builder.AppendLine(); + builder.AppendLine("SAFETY STATE"); + builder.AppendLine("A3 command-bound dchg PASS != ProductionEligible."); + builder.AppendLine("This commissioning action does not save or advance the persisted profile."); + builder.AppendLine("Production automatic dynamic reporting remains OFF until later shadow verification and G2.6 regression acceptance explicitly mark the identity ProductionEligible."); + return builder.ToString(); + } +} From b66783fb29137932bf903fb277a945180838d8f5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:25:47 +0700 Subject: [PATCH 10/77] P1 expose deterministic A3 commissioning hotkey --- DynamicReportCommandBoundWitnessUiBehavior.cs | 125 ++++++++++++------ 1 file changed, 87 insertions(+), 38 deletions(-) diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs index f58e4e92..ee90ed28 100644 --- a/DynamicReportCommandBoundWitnessUiBehavior.cs +++ b/DynamicReportCommandBoundWitnessUiBehavior.cs @@ -23,7 +23,7 @@ public static void Install() handledEventsToo: true); // Legacy observer-only fallback for the dedicated ControlCommandWindow path. - // V3 command authority is the already-existing runtime Diagnostic event; this + // V3/A3 command authority is the already-existing runtime Diagnostic event; this // routed observer is retained only as non-authoritative fallback evidence. EventManager.RegisterClassHandler( typeof(Button), @@ -57,17 +57,21 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) { if (sender is not MainWindow window || Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) || - e.Key != Key.F) + (e.Key != Key.F && e.Key != Key.A)) return; e.Handled = true; var device = window.SelectedDevice; + var a3 = e.Key == Key.A; + var title = a3 ? "G2.6-P1 Deterministic A3" : "G2.5-A2.1 Command-Bound Witness"; if (device is null) { MessageBox.Show( window, - "Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.", - "G2.5-A2.1 Command-Bound Witness", + a3 + ? "Select one IEC 61850 IED first. Deterministic A3 is intentionally bound to one explicit IED, its exact persisted G2.4 envelope, and one explicit existing ARSAS command." + : "Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.", + title, MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -77,8 +81,8 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) { MessageBox.Show( window, - "G2.5-A2.1 is already armed/running.", - "G2.5-A2.1 Command-Bound Witness", + "A command-bound G2 commissioning witness is already armed/running.", + title, MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -86,43 +90,22 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) try { - var answer = MessageBox.Show( - window, - $"Arm G2.5-A2.1 V3 command-bound high-speed stimulus witness for {device.Name} ({device.EndpointText})?\n\n" + - "READ-ONLY MMS WITNESS + ONE EXISTING ARSAS CONTROL COMMAND\n\n" + - "V3 captures the exact command from the ALREADY-EXISTING Iec61850MonitorRuntime Diagnostic event 'Control execution requested:' that is emitted before native control execution. It does not add a hook to the SBOw/Operate transaction.\n\n" + - "After the status shows 'G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN or CLOSE using the normal ARSAS control UI you normally use. Do NOT use an external/manual stimulus for this phase.\n\n" + - "Once the runtime diagnostic identifies the exact control object, the isolated MMS witness narrows read-only sampling to at most six points around the exact ControlStatusReference. Existing ExecuteControlAsync, SBOw, Operate and CommandTermination behavior is NOT modified, delayed, wrapped or re-issued.\n\n" + - "Once 'G2.5-A2.1 COMMAND CAPTURED' appears, do not issue another command. If a transition is seen, A2.1 samples briefly to classify persistent/latched versus momentary/pulse behavior.\n\n" + - "The witness does not access/mutate RCB or DataSet state, does not send GI, does not save/advance the qualification profile, and production dynamic reporting remains OFF. Do not run another G2 hotkey while A2.1 is armed.\n\n" + - "Continue?", - "G2.5-A2.1 V3 Runtime-Diagnostic Witness", - MessageBoxButton.YesNo, - MessageBoxImage.Warning, - MessageBoxResult.No); - if (answer != MessageBoxResult.Yes) - return; - - window.LastStatusText = $"G2.5-A2.1 V3: opening isolated read-only MMS witness for {device.Name} and preparing pre-command baseline…"; - var progress = new Progress(text => window.LastStatusText = text); - var service = new DynamicReportCommandBoundStimulusWitnessServiceV3(); - var result = await service.RunAsync( - window.A21WitnessRuntime, - device, - device.Signals.ToArray(), - progress, - CancellationToken.None); - window.LastStatusText = result.Summary; - var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; - evidenceWindow.ShowDialog(); + if (a3) + await RunDeterministicA3Async(window, device); + else + await RunA21Async(window, device); } catch (Exception ex) { - window.LastStatusText = "G2.5-A2.1 V3 stopped locally; production dynamic reporting remains OFF."; + window.LastStatusText = a3 + ? "G2.6-P1 A3 stopped locally; persisted qualification and production reporting policy remain unchanged." + : "G2.5-A2.1 V3 stopped locally; production dynamic reporting remains OFF."; MessageBox.Show( window, - "G2.5-A2.1 V3 stopped. The witness did not change production reporting policy.\n\n" + ex, - "G2.5-A2.1 V3 Runtime-Diagnostic Witness", + (a3 + ? "G2.6-P1 deterministic A3 stopped. Cleanup remains owned by the core G2.5-A transaction; this action cannot mark ProductionEligible.\n\n" + : "G2.5-A2.1 V3 stopped. The witness did not change production reporting policy.\n\n") + ex, + title, MessageBoxButton.OK, MessageBoxImage.Error); } @@ -131,4 +114,70 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) Interlocked.Exchange(ref _busy, 0); } } + + private static async Task RunA21Async(MainWindow window, Models.Iec61850MonitorDevice device) + { + var answer = MessageBox.Show( + window, + $"Arm G2.5-A2.1 V3 command-bound high-speed stimulus witness for {device.Name} ({device.EndpointText})?\n\n" + + "READ-ONLY MMS WITNESS + ONE EXISTING ARSAS CONTROL COMMAND\n\n" + + "V3 captures the exact command from the ALREADY-EXISTING Iec61850MonitorRuntime Diagnostic event 'Control execution requested:' that is emitted before native control execution. It does not add a hook to the SBOw/Operate transaction.\n\n" + + "After the status shows 'G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN or CLOSE using the normal ARSAS control UI you normally use. Do NOT use an external/manual stimulus for this phase.\n\n" + + "Once the runtime diagnostic identifies the exact control object, the isolated MMS witness narrows read-only sampling to at most six points around the exact ControlStatusReference. Existing ExecuteControlAsync, SBOw, Operate and CommandTermination behavior is NOT modified, delayed, wrapped or re-issued.\n\n" + + "Once 'G2.5-A2.1 COMMAND CAPTURED' appears, do not issue another command. If a transition is seen, A2.1 samples briefly to classify persistent/latched versus momentary/pulse behavior.\n\n" + + "The witness does not access/mutate RCB or DataSet state, does not send GI, does not save/advance the qualification profile, and production dynamic reporting remains OFF. Do not run another G2 hotkey while A2.1 is armed.\n\n" + + "Continue?", + "G2.5-A2.1 V3 Runtime-Diagnostic Witness", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + + window.LastStatusText = $"G2.5-A2.1 V3: opening isolated read-only MMS witness for {device.Name} and preparing pre-command baseline…"; + var progress = new Progress(text => window.LastStatusText = text); + var service = new DynamicReportCommandBoundStimulusWitnessServiceV3(); + var result = await service.RunAsync( + window.A21WitnessRuntime, + device, + device.Signals.ToArray(), + progress, + CancellationToken.None); + window.LastStatusText = result.Summary; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; + evidenceWindow.ShowDialog(); + } + + private static async Task RunDeterministicA3Async(MainWindow window, Models.Iec61850MonitorDevice device) + { + var answer = MessageBox.Show( + window, + $"Arm G2.6-P1 deterministic A3 command-bound dchg proof for {device.Name} ({device.EndpointText})?\n\n" + + "ONE G2.4-PROVEN URCB + DCHG ONLY + ONE EXISTING ARSAS COMMAND\n\n" + + "A3 first opens a READ-ONLY witness association and refuses to arm the report path unless at least one existing ARSAS control object's A2.1 status chain intersects the exact persisted G2.4 member envelope. This avoids spending a breaker operation on a stimulus the A3 DataSet cannot prove.\n\n" + + "The core report transaction temporarily configures ONLY the exact G2.4-proven URCB with dchg enabled, GI/integrity/qchg/dupd disabled, and reason-for-inclusion + DataSet-name enabled. It creates one bounded temporary DataSet and performs mandatory monitor/field/fresh-association cleanup.\n\n" + + "After the status shows 'G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN/CLOSE using the normal ARSAS control UI. A3 observes the existing runtime 'Control execution requested:' diagnostic; it does NOT call, wrap, delay, duplicate or re-issue ExecuteControlAsync/SBOw/Operate.\n\n" + + "PASS requires the post-command read-only witness to see a transition on a qualified command-focus member AND the dchg InformationReport to include the same exact DataSet index, followed by complete cleanup.\n\n" + + "A3 never saves/advances the profile and can never mark ProductionEligible. Production automatic dynamic reporting remains OFF after this test. Do not run another G2 hotkey while A3 is armed.\n\n" + + "Continue?", + "G2.6-P1 Deterministic A3", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + + window.LastStatusText = $"G2.6-P1 A3: preflighting exact G2.4 envelope and command-bound status intersection for {device.Name}…"; + var progress = new Progress(text => window.LastStatusText = text); + var service = new DynamicReportCommandBoundDataChangeCommissioningService(); + var result = await service.RunAsync( + window.A21WitnessRuntime, + device, + device.Signals.ToArray(), + progress, + CancellationToken.None); + window.LastStatusText = result.Summary; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; + evidenceWindow.ShowDialog(); + } } From e55f5b9d4ed92fb46930d1f2ba752bb5bf96030d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:28:11 +0700 Subject: [PATCH 11/77] P1 lock deterministic A3 safety regressions --- .../G26P1DeterministicA3RegressionTests.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs new file mode 100644 index 00000000..3c2a5da9 --- /dev/null +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -0,0 +1,112 @@ +namespace ARSAS.Tests; + +public sealed class G26P1DeterministicA3RegressionTests +{ + [Fact] + public void A3_ObservesExistingRuntimeCommand_AndNeverExecutesControlItself() + { + var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + + Assert.Contains("runtime.Diagnostic += RuntimeDiagnosticHandler", source, StringComparison.Ordinal); + Assert.Contains("DynamicReportCommandBoundStimulusWitnessServiceV3.TryBuildRuntimeIntent", source, StringComparison.Ordinal); + Assert.Contains("Control execution requested:", Read("Services/DynamicReportCommandBoundStimulusWitnessServiceV3.cs"), StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControlAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("WriteControl", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A3_PreflightRequiresQualifiedCommandFocusIntersection_BeforeCoreReportMutation() + { + var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + + var targetGate = source.IndexOf("BuildEligibleCommandTargets(", StringComparison.Ordinal); + var noTargetBlock = source.IndexOf("if (eligibleTargets.Count == 0)", StringComparison.Ordinal); + var coreStart = source.IndexOf("new DynamicReportSpontaneousDataChangeCommissioningService", StringComparison.Ordinal); + + Assert.True(targetGate >= 0); + Assert.True(noTargetBlock > targetGate); + Assert.True(coreStart > noTargetBlock); + Assert.Contains("No RCB mutation was attempted", source, StringComparison.Ordinal); + Assert.Contains("Re-qualify an envelope containing CSWI/XCBR status before A3", source, StringComparison.Ordinal); + } + + [Fact] + public void A3_PassRequiresSameDataSetIndexForCommandTransitionAndDchgReport() + { + var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + + Assert.Contains("CorrelateIndexes(coreResult.IncludedIndexes, changedIndexes)", source, StringComparison.Ordinal); + Assert.Contains("coreResult.SpontaneousDataChangeProven &&", source, StringComparison.Ordinal); + Assert.Contains("witnessResult.CommandCaptured &&", source, StringComparison.Ordinal); + Assert.Contains("witnessResult.CommandBoundTransitionProven &&", source, StringComparison.Ordinal); + Assert.Contains("correlatedIndexes.Length > 0", source, StringComparison.Ordinal); + Assert.Contains("var success = coreResult.IsSuccess && correlation", source, StringComparison.Ordinal); + } + + [Fact] + public void A3_ReusesStrictDchgOnlyCoreAndMandatoryCleanup() + { + var wrapper = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + var core = Read("Services/DynamicReportSpontaneousDataChangeCommissioningService.cs"); + + Assert.Contains("DynamicReportSpontaneousDataChangeCommissioningService", wrapper, StringComparison.Ordinal); + Assert.Contains("TriggerOptionsDataChange", core, StringComparison.Ordinal); + Assert.Contains("OptionalFieldsReasonForInclusionAndDataSetName", core, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: false", core, StringComparison.Ordinal); + Assert.Contains("SpontaneousReportHasForbiddenReason", core, StringComparison.Ordinal); + Assert.Contains("MonitorCleanupSucceeded", core, StringComparison.Ordinal); + Assert.Contains("ProofFieldRestoreSucceeded", core, StringComparison.Ordinal); + Assert.Contains("FreshCleanupClosureSucceeded", core, StringComparison.Ordinal); + } + + [Fact] + public void A3_CannotAdvanceProductionEligibility() + { + var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + var evidenceWindow = Read("DynamicReportQualificationResultWindow.G26P1A3.cs"); + + Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", source, StringComparison.Ordinal); + Assert.Contains("profile remains InformationReportProven", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("A3 command-bound dchg PASS != ProductionEligible", evidenceWindow, StringComparison.Ordinal); + Assert.Contains("Production automatic dynamic reporting remains OFF", evidenceWindow, StringComparison.Ordinal); + } + + [Fact] + public void A3_HasSeparateExplicitHotkeyFromA21Witness() + { + var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); + + Assert.Contains("(e.Key != Key.F && e.Key != Key.A)", ui, StringComparison.Ordinal); + Assert.Contains("var a3 = e.Key == Key.A", ui, StringComparison.Ordinal); + Assert.Contains("DynamicReportCommandBoundDataChangeCommissioningService", ui, StringComparison.Ordinal); + Assert.Contains("G2.6-P1 A3 READY", Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"), StringComparison.Ordinal); + } + + [Fact] + public void EngineLock_PinsProductionConsumerButKeepsCurrentFieldStateLocked() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("a2b2265af54afd87b98aadcf63e302725c97d347", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #97", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); + Assert.Contains("production automatic dynamic reporting remains OFF", engineLock, StringComparison.OrdinalIgnoreCase); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} From 5f974a5b8909594d9542de958b3273cc8e3bc419 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:28:32 +0700 Subject: [PATCH 12/77] P1 document deterministic A3 field contract --- docs/G2_6_P1_DETERMINISTIC_A3.md | 97 ++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/G2_6_P1_DETERMINISTIC_A3.md diff --git a/docs/G2_6_P1_DETERMINISTIC_A3.md b/docs/G2_6_P1_DETERMINISTIC_A3.md new file mode 100644 index 00000000..d67407a6 --- /dev/null +++ b/docs/G2_6_P1_DETERMINISTIC_A3.md @@ -0,0 +1,97 @@ +# G2.6-P1 — Deterministic Command-Bound A3 dchg Proof + +## Goal + +Convert the previous generic/manual G2.5-A dchg stimulus into one deterministic ARSAS-owned evidence chain: + +`existing ARSAS control command -> qualified MMS status transition -> Dynamic URCB InformationReport(reason=data-change) -> cleanup` + +This is a commissioning proof only. It does **not** mark an IED `ProductionEligible` and it does **not** enable production automatic dynamic reporting. + +## Entry point + +Select the target IEC 61850 IED in ARSAS, then press: + +`Ctrl + Shift + A` + +The older A2.1 read-only command witness remains available separately on `Ctrl + Shift + F`. + +## Preflight gates + +Before the report path is allowed to mutate an RCB, P1 requires: + +1. the persisted profile is identity-compatible and exactly `InformationReportProven`; +2. the G2.4 RCB activation proof and InformationReport proof are successful; +3. the exact G2.4 member sequence still resolves on the live IED; +4. at least one existing ARSAS control object exposes an exact `ControlStatusReference`; +5. the A2.1 status/focus chain for that command intersects the exact G2.4-proven DataSet member sequence; +6. no control command is already busy. + +If the command/status chain does not intersect the qualified DataSet, A3 stops **before** the core report transaction is started. The operator is told to re-qualify an envelope containing the relevant CSWI/XCBR status instead of spending a breaker operation on an unprovable stimulus. + +## Armed transaction + +The existing `DynamicReportSpontaneousDataChangeCommissioningService` remains authoritative for the report transaction: + +- one exact G2.4-proven URCB; +- one bounded temporary dynamic DataSet; +- `TrgOps`: dchg only; +- GI disabled; +- integrity disabled; +- qchg disabled; +- dupd disabled; +- `OptFlds`: reason-for-inclusion + DataSet-name; +- exact RptID/DataSet/member/reason validation; +- report monitor cleanup; +- TrgOps/OptFlds restoration; +- fresh-association cleanup closure. + +A separate auxiliary MMS association is strictly read-only. It captures the final pre-command baseline and then samples only the qualified A2.1 command-focus members at high speed. + +## Command authority + +P1 does not call or wrap `ExecuteControlAsync`. + +The operator issues exactly one already-proven safe OPEN/CLOSE through the normal ARSAS control UI after this status appears: + +`G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND` + +The A3 witness consumes the already-existing `Iec61850MonitorRuntime.Diagnostic` entry beginning with: + +`Control execution requested:` + +That diagnostic is emitted by the existing runtime before native control execution. P1 therefore observes the established control path without inserting a new SBO/SBOw/Operate hook, delaying it, or re-issuing it. + +## PASS contract + +A3 PASS requires all of the following in the same bounded armed window: + +1. core dchg-only activation is proven; +2. the exact existing ARSAS command is captured after the final read-only baseline is ready; +3. at least one qualified command-focus MMS member changes after that command; +4. a valid spontaneous InformationReport is received with reason-for-inclusion `data-change`; +5. the report includes at least one **same exact DataSet index** as the post-command qualified transition; +6. report monitor cleanup succeeds; +7. temporary proof fields are restored; +8. fresh-association cleanup closure succeeds. + +The evidence window records the command object/request, transition member/index/before/after values, report included indexes/reasons, correlated indexes, and cleanup state. + +## Failure localization + +The combined proof separates several useful failure classes: + +- report path never arms -> activation/configuration problem; +- command is not captured -> ARSAS stimulus/capture problem; +- command captured but no qualified transition -> wrong/non-changing qualified member or physical/control feedback problem; +- command-bound qualified transition occurs but no dchg report -> report emission/receive-path problem; +- dchg report arrives but includes different indexes -> report/member correlation problem; +- report succeeds but cleanup fails -> production remains ineligible and cleanup must be fixed first. + +## Production boundary + +A3 success is intentionally weaker than production eligibility. + +P1 never calls `MarkProductionEligible`, never saves a promoted qualification profile, and never changes Smart Auto policy. After A3, the persisted field state remains `InformationReportProven` until later shadow verification and the complete G2.6 regression acceptance explicitly advance it. + +The ARIEC engine pinned by P1 already contains the P0 production consumer (PR #97), but that consumer remains fail-closed for the current field IED while the profile is below `ProductionEligible`. From 743bf1082b357438c7f3f33c9bf8666dbe11f899 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:30:03 +0700 Subject: [PATCH 13/77] P1 pin merged ARIEC production consumer on main --- 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 88fef07b..08bc0da5 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "repository": "masarray/ARIEC61850", - "ref": "g2.6-production-dynamic-consumer", - "commit": "a2b2265af54afd87b98aadcf63e302725c97d347", + "ref": "main", + "commit": "aa2ddfb47af5f3b806858553568792fbc21a64f1", "sourcePullRequest": 97, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 closes the engine-side P6.2-B production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only the exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. ARSAS P1 pins this immutable engine commit for deterministic A3 development; the current field profile remains InformationReportProven and production automatic dynamic reporting remains OFF until later shadow/regression gates explicitly mark it ProductionEligible." + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 is merged on main at aa2ddfb47af5f3b806858553568792fbc21a64f1 and closes the engine-side P6.2-B production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only the exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. ARSAS P1 pins this immutable main commit for deterministic A3 development; the current field profile remains InformationReportProven and production automatic dynamic reporting remains OFF until later shadow/regression gates explicitly mark it ProductionEligible." } From 83eea7f0005566dc7c82750a12e70d892809f63c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:30:24 +0700 Subject: [PATCH 14/77] P1 align engine-lock regression with merged main commit --- tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index 3c2a5da9..e26471fe 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -84,11 +84,12 @@ public void A3_HasSeparateExplicitHotkeyFromA21Witness() } [Fact] - public void EngineLock_PinsProductionConsumerButKeepsCurrentFieldStateLocked() + public void EngineLock_PinsMergedProductionConsumerButKeepsCurrentFieldStateLocked() { var engineLock = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("a2b2265af54afd87b98aadcf63e302725c97d347", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("aa2ddfb47af5f3b806858553568792fbc21a64f1", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #97", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("production automatic dynamic reporting remains OFF", engineLock, StringComparison.OrdinalIgnoreCase); From 89567de022516714edb959b7cbe731c2a2517d4e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:31:26 +0700 Subject: [PATCH 15/77] P1 align dchg core regression with exact contract --- tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index e26471fe..74e9f108 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -50,8 +50,9 @@ public void A3_ReusesStrictDchgOnlyCoreAndMandatoryCleanup() var core = Read("Services/DynamicReportSpontaneousDataChangeCommissioningService.cs"); Assert.Contains("DynamicReportSpontaneousDataChangeCommissioningService", wrapper, StringComparison.Ordinal); - Assert.Contains("TriggerOptionsDataChange", core, StringComparison.Ordinal); - Assert.Contains("OptionalFieldsReasonForInclusionAndDataSetName", core, StringComparison.Ordinal); + Assert.Contains("internal const string TemporaryTriggerOptions = \"dchg\"", core, StringComparison.Ordinal); + Assert.Contains("internal const string TemporaryOptionalFields = \"reason-for-inclusion data-set-name\"", core, StringComparison.Ordinal); + Assert.Contains("GI=false, integrity=false, qchg=false, dupd=false", core, StringComparison.Ordinal); Assert.Contains("triggerGeneralInterrogation: false", core, StringComparison.Ordinal); Assert.Contains("SpontaneousReportHasForbiddenReason", core, StringComparison.Ordinal); Assert.Contains("MonitorCleanupSucceeded", core, StringComparison.Ordinal); From a2cd472d5430487cbe423f8c24146fa1f2691026 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:35:05 +0700 Subject: [PATCH 16/77] P1 advance G1 engine-lock regression to merged G2.6 consumer --- .../G1ControlCorrectnessRegressionTests.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index f30b60e4..8f75c955 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -5,18 +5,18 @@ namespace ARSAS.Tests; public sealed class G1ControlCorrectnessRegressionTests { [Fact] - public void EngineLock_PinsReviewedG24P1EngineAndPreservesExactG1FieldProvenAncestry() + public void EngineLock_PinsReviewedG26ProductionConsumerAndPreservesExactG1FieldProvenAncestry() { var root = RepoRoot(); using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"))); var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("26c85400a4da230c4429e6302847f230385b6687", json.GetProperty("commit").GetString()); - Assert.Equal(95, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("aa2ddfb47af5f3b806858553568792fbc21a64f1", json.GetProperty("commit").GetString()); + Assert.Equal(97, json.GetProperty("sourcePullRequest").GetInt32()); var purpose = json.GetProperty("purpose").GetString() ?? string.Empty; - // G2.4 may advance the engine pin only while the field-proven G1/G2.3/P0/P1 ancestry + // G2.6 may advance the engine pin only while the field-proven G1/G2.3/P0/P1 ancestry // and all non-regression reporting/control safety statements remain explicit. Assert.Contains("a18e550d07f7bbe4ff7753c180b02615075f6292", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("signed primitive constraints", purpose, StringComparison.OrdinalIgnoreCase); @@ -37,7 +37,14 @@ public void EngineLock_PinsReviewedG24P1EngineAndPreservesExactG1FieldProvenAnce Assert.Contains("C0A851F0", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("192.168.81.240", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("Owner mismatch or unsupported encoding remains a hard failure", purpose, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Production automatic dynamic BRCB/URCB activation remains quarantined", purpose, StringComparison.OrdinalIgnoreCase); + + // PR #97 adds the production consumer but remains strictly fail-closed unless the + // persisted identity is ProductionEligible and exact RCB/member evidence matches. + Assert.Contains("PR #97", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("identity-compatible ProductionEligible profile", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact InformationReport-proven RCB/member evidence", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("current field profile remains InformationReportProven", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("production automatic dynamic reporting remains OFF", purpose, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -127,6 +134,7 @@ public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() { var engineLock = File.ReadAllText(Path.Combine(RepoRoot(), "engines", "ARIEC61850.lock.json")); Assert.Contains("PR #89 quarantines automatic full dynamic DataSet activation", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("production automatic dynamic reporting remains OFF", engineLock, StringComparison.OrdinalIgnoreCase); var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); Assert.Contains("SmartReconnectPolicy", runtime, StringComparison.Ordinal); From 5fb20acedff2614a30882358411404f594d8db1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 14:35:24 +0700 Subject: [PATCH 17/77] P1 align strict dchg regression with actual validator wording --- tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index 74e9f108..1fda0b85 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -54,7 +54,7 @@ public void A3_ReusesStrictDchgOnlyCoreAndMandatoryCleanup() Assert.Contains("internal const string TemporaryOptionalFields = \"reason-for-inclusion data-set-name\"", core, StringComparison.Ordinal); Assert.Contains("GI=false, integrity=false, qchg=false, dupd=false", core, StringComparison.Ordinal); Assert.Contains("triggerGeneralInterrogation: false", core, StringComparison.Ordinal); - Assert.Contains("SpontaneousReportHasForbiddenReason", core, StringComparison.Ordinal); + Assert.Contains("carries a non-dchg reason under a dchg-only lease", core, StringComparison.Ordinal); Assert.Contains("MonitorCleanupSucceeded", core, StringComparison.Ordinal); Assert.Contains("ProofFieldRestoreSucceeded", core, StringComparison.Ordinal); Assert.Contains("FreshCleanupClosureSucceeded", core, StringComparison.Ordinal); From 62984b32e90a327536a949a2a2e173666981d1e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 15:16:32 +0700 Subject: [PATCH 18/77] G2.6 P1: add transactional command-focus requalification --- ...ocusRequalificationCommissioningService.cs | 653 ++++++++++++++++++ 1 file changed, 653 insertions(+) create mode 100644 Services/DynamicReportCommandFocusRequalificationCommissioningService.cs diff --git a/Services/DynamicReportCommandFocusRequalificationCommissioningService.cs b/Services/DynamicReportCommandFocusRequalificationCommissioningService.cs new file mode 100644 index 00000000..b418256c --- /dev/null +++ b/Services/DynamicReportCommandFocusRequalificationCommissioningService.cs @@ -0,0 +1,653 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportCommandFocusRequalificationAssessment +{ + public bool IsSuccess { get; init; } + public bool RequiresRequalification { get; init; } + public string Summary { get; init; } = string.Empty; + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +internal sealed class DynamicReportCommandFocusRequalificationResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public bool LiveProfileReplaced { get; init; } + public bool FreshCleanupClosureSucceeded { get; init; } + public string Summary { get; init; } = string.Empty; + public ArMms.MmsDynamicReportQualificationProfile? OriginalProfile { get; init; } + public ArMms.MmsDynamicReportQualificationProfile? SavedProfile { get; init; } + public DynamicReportActivationCommissioningResult? ActivationResult { get; init; } + public DynamicReportCleanupClosureCommissioningResult? CleanupClosureResult { get; init; } + public IReadOnlyList QualifiedMemberReferences { get; init; } = Array.Empty(); + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// Field-discovered G2.6-P1 recovery path for an InformationReportProven profile whose +/// exact member envelope cannot witness any existing ARSAS command. +/// +/// The live profile is treated as immutable until a completely separate staging profile +/// has passed all of the following: +/// 1. exact command-status discovery + direct read validation; +/// 2. explicit dynamic NamedVariableList qualification with cleanup continuity; +/// 3. G2.4 V2 one-URCB activation + actual InformationReport proof; +/// 4. G2.4-C fresh-association read-only cleanup closure. +/// +/// Staging uses a private temporary profile-store root. Only after every stage succeeds, +/// and after the live profile is re-read to prove it did not change concurrently, is the +/// new InformationReportProven profile atomically moved into the normal store. This +/// service never executes a control command and can never mark ProductionEligible. +/// +internal sealed class DynamicReportCommandFocusRequalificationCommissioningService +{ + private const int MaximumCommandFocusMembers = DynamicReportActivationCommissioningService.MaximumG24Members; + private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10); + + private readonly DynamicReportQualificationProfileStore _liveProfileStore; + + public DynamicReportCommandFocusRequalificationCommissioningService( + DynamicReportQualificationProfileStore? liveProfileStore = null) + { + _liveProfileStore = liveProfileStore ?? new DynamicReportQualificationProfileStore(); + } + + public async Task AssessAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var evidence = new List + { + "G2.6-P1 recovery assessment: READ ONLY; no DataSet/RCB/profile/control mutation is permitted." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return AssessmentFailure("Command-focus recovery identity preflight failed: " + ex.Message, evidence); + } + + var loaded = await _liveProfileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + evidence.Add($"Recovery assessment profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + if (!IsInformationReportProven(loaded.Profile) || !loaded.IsValid) + { + return AssessmentFailure( + "Command-focus recovery requires the exact identity-compatible InformationReportProven profile.", + evidence); + } + + var commandSignals = GetCommandSignals(fullModelSignals); + if (commandSignals.Length == 0) + return AssessmentFailure("No live ARSAS control object exposes ControlStatusReference.", evidence); + if (commandSignals.Any(signal => signal.ControlCommandBusy)) + return AssessmentFailure("A control command is already in progress; recovery assessment must be performed while controls are idle.", evidence); + + await using var session = new ArMms.MmsClientSession(); + try + { + await session.ConnectAsync( + device.IpAddress, + device.Port, + AuxiliaryAssociationTimeout, + cancellationToken).ConfigureAwait(false); + var discovery = await session.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + + var qualifiedReferences = loaded.Profile!.RcbActivationProof!.MemberReferences.ToArray(); + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + discovery.IedDirectory, + qualifiedReferences, + out _, + out var memberReason)) + { + evidence.Add("Recovery assessment exact member resolution failed: " + memberReason); + return AssessmentFailure("The existing InformationReportProven envelope no longer resolves exactly on the live IED.", evidence); + } + + var eligible = DynamicReportCommandBoundDataChangeCommissioningService.BuildEligibleCommandTargets( + discovery.IedDirectory, + commandSignals, + qualifiedReferences, + evidence); + if (eligible.Count > 0) + { + evidence.Add("Recovery assessment: existing envelope already has command-focus intersection: " + + string.Join(" | ", eligible.Select(item => item.Signal.ObjectReference))); + return new DynamicReportCommandFocusRequalificationAssessment + { + IsSuccess = true, + RequiresRequalification = false, + Summary = "The existing InformationReportProven envelope already contains an eligible ARSAS command-focus status member; no requalification is required.", + EvidenceLines = evidence.ToArray() + }; + } + + evidence.Add("Recovery assessment: zero existing command-focus intersections. Live profile remains untouched."); + return new DynamicReportCommandFocusRequalificationAssessment + { + IsSuccess = true, + RequiresRequalification = true, + Summary = "The existing InformationReportProven envelope cannot witness an ARSAS command. Transactional command-focus requalification is required before deterministic A3 can arm.", + EvidenceLines = evidence.ToArray() + }; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"Recovery assessment exception: {ex.GetType().Name}: {ex.Message}"); + return AssessmentFailure("The read-only recovery assessment could not complete.", evidence); + } + } + + public async Task RunAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var evidence = new List + { + "G2.6-P1 command-focus requalification contract: stage everything away from the live profile, prove activation/report/cleanup completely, then atomically replace only with InformationReportProven.", + "G2.6-P1 recovery control safety: ZERO control execution. Existing ARSAS SBO/SBOw/Operate path is not called, wrapped, delayed or re-issued.", + "G2.6-P1 recovery production safety: ProductionEligible is forbidden; production automatic dynamic reporting remains OFF." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("Command-focus requalification identity preflight failed: " + ex.Message, evidence); + } + + var originalLoad = await _liveProfileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (!originalLoad.IsValid || !IsInformationReportProven(originalLoad.Profile)) + { + evidence.Add($"Live profile rejected: exists={originalLoad.Exists}; valid={originalLoad.IsValid}; state={originalLoad.Profile?.State.ToString() ?? "-"}; reason={originalLoad.Reason}"); + return Blocked("Transactional command-focus recovery requires the exact existing InformationReportProven profile.", evidence, originalLoad.Profile); + } + + var originalProfile = originalLoad.Profile!; + var commandSignals = GetCommandSignals(fullModelSignals); + if (commandSignals.Length == 0) + return Blocked("No ARSAS control object exposes ControlStatusReference; recovery will not guess a status point.", evidence, originalProfile); + if (commandSignals.Any(signal => signal.ControlCommandBusy)) + return Blocked("A control command is already in progress. Recovery must complete before the one A3 test command.", evidence, originalProfile); + + var stagingRoot = Path.Combine(Path.GetTempPath(), "ARSAS", "g26-p1-command-focus-" + Guid.NewGuid().ToString("N")); + try + { + progress?.Report("G2.6-P1 recovery: discovering exact command-status points and qualifying a staging-only dynamic DataSet…"); + var envelope = await BuildStagedEnvelopeAsync( + device, + fullModelSignals, + commandSignals, + identity, + evidence, + cancellationToken).ConfigureAwait(false); + if (!envelope.IsSuccess || envelope.Profile is null) + { + return Failed( + envelope.Summary, + evidence, + originalProfile, + envelope.MemberReferences); + } + + var stagingStore = new DynamicReportQualificationProfileStore(stagingRoot); + await stagingStore.SaveAsync(envelope.Profile, cancellationToken).ConfigureAwait(false); + evidence.Add($"Staging profile persisted outside live store: state={envelope.Profile.State}; members={envelope.Profile.ProvenSafeMemberCount}; liveProfileTouched=false"); + + progress?.Report("G2.6-P1 recovery: staging envelope qualified; proving one-URCB activation + actual InformationReport without touching the live profile…"); + var activationService = new DynamicReportActivationCommissioningServiceV2(stagingStore); + var activation = await activationService.RunAsync( + device, + fullModelSignals, + cancellationToken).ConfigureAwait(false); + evidence.Add("Staged G2.4 V2: " + activation.Summary); + evidence.AddRange(activation.EvidenceLines.Select(line => "staged/G2.4: " + line)); + + if (!activation.IsSuccess || !activation.CleanupSucceeded || !IsInformationReportProven(activation.SavedProfile)) + { + return Failed( + "Staged command-focus G2.4 did not close activation + actual InformationReport + cleanup. The original live InformationReportProven profile was not changed.", + evidence, + originalProfile, + envelope.MemberReferences, + activation); + } + + progress?.Report("G2.6-P1 recovery: staged report proof passed; opening a fresh READ-ONLY association to close RCB/DataSet cleanup…"); + var closureService = new DynamicReportCleanupClosureCommissioningService(stagingStore); + var closure = await closureService.RunAsync( + device, + fullModelSignals, + cancellationToken).ConfigureAwait(false); + evidence.Add("Staged G2.4-C: " + closure.Summary); + evidence.AddRange(closure.EvidenceLines.Select(line => "staged/G2.4-C: " + line)); + + if (!closure.IsSuccess) + { + return Failed( + "Staged command-focus report proof passed, but fresh-association cleanup closure did not. The original live profile remains untouched.", + evidence, + originalProfile, + envelope.MemberReferences, + activation, + closure); + } + + var finalProfile = activation.SavedProfile!; + if (!FinalProfileMatchesCommandFocus(finalProfile, envelope.CommandStatusReferences, out var finalReason)) + { + evidence.Add("Final staged profile rejected: " + finalReason); + return Failed( + "Staged proof completed but the resulting InformationReportProven profile lost the exact command-focus member invariant. Live profile was not changed.", + evidence, + originalProfile, + envelope.MemberReferences, + activation, + closure); + } + + // Optimistic concurrency gate: a long physical staging transaction must never + // overwrite a live qualification profile that another commissioning action + // changed while this recovery was running. + var currentLoad = await _liveProfileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (!currentLoad.IsValid || currentLoad.Profile is null || !SameProfileEvidence(originalProfile, currentLoad.Profile)) + { + evidence.Add("Live profile concurrency gate failed: the persisted evidence changed during staging. No replacement was attempted."); + return Failed( + "The live qualification profile changed while command-focus staging was running. Recovery aborted rather than overwrite newer evidence.", + evidence, + originalProfile, + envelope.MemberReferences, + activation, + closure); + } + + await _liveProfileStore.SaveAsync(finalProfile, cancellationToken).ConfigureAwait(false); + evidence.Add($"LIVE PROFILE ATOMIC REPLACEMENT PASS: oldState={originalProfile.State}; newState={finalProfile.State}; rcb={finalProfile.RcbActivationProof?.RcbReference}; members={finalProfile.RcbActivationProof?.MemberReferences.Count}; ProductionEligible=false"); + evidence.Add("G2.6-P1 recovery complete: the new exact InformationReportProven envelope contains command-status evidence; deterministic A3 may now be armed. Production automatic dynamic reporting remains OFF."); + + progress?.Report("G2.6-P1 recovery PASS — command-focus profile is InformationReportProven and cleanup-closed. Re-arming deterministic A3 automatically; DO NOT command until the exact A3 READY marker appears."); + return new DynamicReportCommandFocusRequalificationResult + { + IsSuccess = true, + LiveProfileReplaced = true, + FreshCleanupClosureSucceeded = true, + Summary = "G2.6-P1 command-focus requalification PASS: a staging-only envelope passed dynamic DataSet qualification, one-URCB actual InformationReport proof and fresh cleanup closure; only then was the live profile atomically replaced at InformationReportProven. A3 can be re-armed; ProductionEligible remains OFF.", + OriginalProfile = originalProfile, + SavedProfile = finalProfile, + ActivationResult = activation, + CleanupClosureResult = closure, + QualifiedMemberReferences = envelope.MemberReferences, + EvidenceLines = evidence.ToArray() + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException or UnauthorizedAccessException or ArgumentException) + { + evidence.Add($"G2.6-P1 recovery exception: {ex.GetType().Name}: {ex.Message}"); + return Failed( + "Transactional command-focus requalification stopped before atomic live-profile replacement. The previous InformationReportProven profile remains authoritative.", + evidence, + originalProfile); + } + finally + { + TryDeleteStagingRoot(stagingRoot, evidence); + } + } + + private static async Task BuildStagedEnvelopeAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IReadOnlyList commandSignals, + ArMms.MmsDynamicReportIedIdentity identity, + ICollection evidence, + CancellationToken cancellationToken) + { + await using var session = new ArMms.MmsClientSession(); + try + { + await session.ConnectAsync( + device.IpAddress, + device.Port, + AuxiliaryAssociationTimeout, + cancellationToken).ConfigureAwait(false); + evidence.Add($"Recovery staging association ready: state={session.State}; localTcpAddress={TextOrDash(session.LocalTcpAddress)}"); + + var discovery = await session.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add("Recovery staging discovery: " + discovery.Summary); + + var statusPoints = DynamicReportCommandBoundStimulusWitnessService.ResolveCommandStatusPoints( + discovery.IedDirectory, + commandSignals, + evidence); + if (statusPoints.Count == 0) + return StagedEnvelopeResult.Fail("No ControlStatusReference resolved to a live ST/stVal MMS point."); + + var candidates = SelectCommandFocusCandidates(discovery.IedDirectory, statusPoints) + .Take(MaximumCommandFocusMembers) + .ToArray(); + if (candidates.Length < 2) + { + evidence.Add("Recovery staging candidates: " + string.Join(" | ", candidates.Select(point => point.UserReference))); + return StagedEnvelopeResult.Fail("Command-focus recovery requires at least two bounded ST/stVal candidates so the G2.3 multi-member envelope gate is not weakened."); + } + + var validated = new List(); + var validatedMms = new List(); + foreach (var point in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await session.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false); + evidence.Add($"Recovery direct-read candidate: ref={point.UserReference}; mms={point.MmsReference}; success={read.IsSuccess}; result={read.Message}"); + if (!read.IsSuccess) + { + if (!session.IsMmsInitiated) + return StagedEnvelopeResult.Fail("The staging association was lost during direct-read validation."); + continue; + } + + validated.Add(point.ToObjectReference()); + validatedMms.Add(point.MmsReference); + } + + if (validated.Count < 2) + return StagedEnvelopeResult.Fail("Fewer than two command-focus candidates passed exact direct MMS-read validation."); + + var commandStatusReferences = statusPoints.Values + .Select(point => point.MmsReference) + .Where(status => validatedMms.Any(candidate => SameMms(candidate, status))) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (commandStatusReferences.Length == 0) + return StagedEnvelopeResult.Fail("Direct-read validation removed every exact ControlStatusReference; recovery will not qualify a status envelope by inference."); + + var dataSetReference = BuildTemporaryDataSetReference(validated[0].Domain); + evidence.Add($"Recovery qualification dataset={dataSetReference}; candidates={validated.Count}; commandStatusCandidates={commandStatusReferences.Length}; liveProfileTouched=false"); + + var coordinator = await session.RunDynamicDataSetQualificationCommissioningAsync( + dataSetReference, + validated, + new ArMms.MmsDynamicDataSetQualificationCoordinatorOptions + { + ExecutionMode = ArMms.MmsDynamicDataSetQualificationExecutionMode.ExplicitCommissioning, + MaxAttempts = 16, + LocalizeFailedBatch = true, + Ladder = new ArMms.MmsDynamicDataSetQualificationLadderOptions + { + Milestones = [1, 4, 8], + ApplicationSafetyMemberLimit = MaximumCommandFocusMembers, + IncludeTerminalCandidateCount = true + }, + Probe = new ArMms.MmsDynamicDataSetQualificationProbeOptions + { + ApplicationSafetyMemberLimit = MaximumCommandFocusMembers, + RejectKnownNegotiatedPduOverflow = true + } + }, + discovery.IedDirectory, + cancellationToken).ConfigureAwait(false); + + evidence.Add("Recovery qualification coordinator: " + coordinator.Summary); + foreach (var attempt in coordinator.Attempts) + { + evidence.Add($"Recovery qualification attempt {attempt.AttemptId}: members={attempt.MemberCount}; success={attempt.IsQualificationSuccess}; associationSurvived={attempt.AssociationSurvived}; cleanup={attempt.CleanupSucceeded}; stage={attempt.FailureStage}"); + } + evidence.AddRange(coordinator.Warnings.Select(warning => "Recovery qualification warning: " + warning)); + + if (coordinator.RequiresFreshAssociation || + !coordinator.Assessment.HasMultiMemberEnvelopeCandidate || + string.IsNullOrWhiteSpace(coordinator.EnvelopeCandidateAttemptId)) + { + return StagedEnvelopeResult.Fail( + coordinator.RequiresFreshAssociation + ? "Dynamic DataSet qualification did not prove association/cleanup continuity." + : "Dynamic DataSet qualification did not produce a cleanup-safe multi-member envelope."); + } + + var acceptedEnvelope = ArMms.MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope( + coordinator.Assessment, + coordinator.EnvelopeCandidateAttemptId); + var profile = ArMms.MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile( + identity, + acceptedEnvelope, + coordinator.Assessment, + capacityEvidence: null, + sourceEvidenceId: $"arsas-g2.6-p1-command-focus-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}", + nowUtc: DateTimeOffset.UtcNow); + + var accepted = profile.AcceptedEnvelope?.ExactProvenMemberReferences?.ToArray() ?? Array.Empty(); + var acceptedStatuses = commandStatusReferences.Where(status => accepted.Any(member => SameMms(member, status))).ToArray(); + if (acceptedStatuses.Length == 0) + { + return StagedEnvelopeResult.Fail("The accepted exact envelope did not retain any exact ControlStatusReference member."); + } + + evidence.Add($"Recovery staged EnvelopeQualified PASS: members={accepted.Length}; exactCommandStatuses={acceptedStatuses.Length}; state={profile.State}; liveProfileTouched=false"); + evidence.Add("Recovery staged exact members: " + string.Join(" | ", accepted)); + return new StagedEnvelopeResult + { + IsSuccess = true, + Summary = "Command-focus dynamic DataSet envelope qualified in staging.", + Profile = profile, + MemberReferences = accepted, + CommandStatusReferences = acceptedStatuses + }; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException or ArgumentException) + { + evidence.Add($"Recovery staging qualification exception: {ex.GetType().Name}: {ex.Message}"); + return StagedEnvelopeResult.Fail("Command-focus staging qualification ended on a transport/protocol/policy exception."); + } + } + + private static IReadOnlyList SelectCommandFocusCandidates( + ArMms.MmsIedModelDirectory directory, + IReadOnlyDictionary statusPoints) + { + var result = new List(); + + // Exact ControlStatusReference values come first. With multiple live controls this + // makes the bounded G2.4 envelope useful for more than one normal ARSAS command. + foreach (var pair in statusPoints.OrderBy(item => item.Key.ObjectReference, StringComparer.OrdinalIgnoreCase)) + AddDistinct(result, pair.Value); + + // Then add the same A2.1 focus chain used by the physical command witness. This + // naturally adds XCBR/CSWI/XSWI Pos.stVal corroboration when the IED exposes it. + foreach (var pair in statusPoints.OrderBy(item => item.Key.ObjectReference, StringComparer.OrdinalIgnoreCase)) + { + foreach (var point in DynamicReportCommandBoundStimulusWitnessService.BuildFocusChain(directory, pair.Value)) + { + if (!point.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + point.IsControlAttribute || point.IsReportAttribute || + !(point.DataObjectPath.Equals("stVal", StringComparison.OrdinalIgnoreCase) || + point.DataObjectPath.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase))) + continue; + AddDistinct(result, point); + } + } + + return result.Take(MaximumCommandFocusMembers).ToArray(); + } + + private static void AddDistinct(List target, ArMms.MmsFcResolvedPoint point) + { + if (target.Any(existing => SameMms(existing.MmsReference, point.MmsReference))) + return; + target.Add(point); + } + + private static bool FinalProfileMatchesCommandFocus( + ArMms.MmsDynamicReportQualificationProfile profile, + IReadOnlyList commandStatusReferences, + out string reason) + { + if (!IsInformationReportProven(profile)) + { + reason = $"final state/proofs are incomplete: state={profile.State}"; + return false; + } + + var members = profile.RcbActivationProof!.MemberReferences; + if (!commandStatusReferences.Any(status => members.Any(member => SameMms(member, status)))) + { + reason = "final G2.4 exact member sequence has no retained command-status member"; + return false; + } + + if (profile.State == ArMms.MmsDynamicReportQualificationState.ProductionEligible) + { + reason = "staging unexpectedly produced ProductionEligible, which is forbidden in P1 recovery"; + return false; + } + + reason = "exact InformationReportProven command-focus member invariant passed"; + return true; + } + + private static bool SameProfileEvidence( + ArMms.MmsDynamicReportQualificationProfile expected, + ArMms.MmsDynamicReportQualificationProfile current) + { + if (expected.State != current.State || + !string.Equals(expected.Identity.StableIdentityKey, current.Identity.StableIdentityKey, StringComparison.OrdinalIgnoreCase) || + !string.Equals(expected.Identity.ModelFingerprint, current.Identity.ModelFingerprint, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.Equals(expected.RcbActivationProof?.EvidenceId, current.RcbActivationProof?.EvidenceId, StringComparison.Ordinal) || + !string.Equals(expected.InformationReportProof?.EvidenceId, current.InformationReportProof?.EvidenceId, StringComparison.Ordinal)) + return false; + + var expectedMembers = expected.RcbActivationProof?.MemberReferences ?? Array.Empty(); + var currentMembers = current.RcbActivationProof?.MemberReferences ?? Array.Empty(); + return expectedMembers.Count == currentMembers.Count && + expectedMembers.Zip(currentMembers).All(pair => SameMms(pair.First, pair.Second)); + } + + private static SignalDefinition[] GetCommandSignals(IReadOnlyList signals) + => signals + .Where(signal => signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + .Distinct() + .OrderBy(signal => signal.ObjectReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static bool IsInformationReportProven(ArMms.MmsDynamicReportQualificationProfile? profile) + => profile is not null && + profile.State == ArMms.MmsDynamicReportQualificationState.InformationReportProven && + profile.RcbActivationProof?.IsSuccess == true && + profile.InformationReportProof?.IsSuccess == true; + + private static string BuildTemporaryDataSetReference(string domain) + { + if (string.IsNullOrWhiteSpace(domain)) + throw new InvalidOperationException("The first command-focus member has no logical-device domain."); + return $"{domain.Trim()}/LLN0.ARQ{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; + } + + private static bool SameMms(string? left, string? right) + => ArMms.MmsFcReferenceNormalizer.NormalizeMmsReference(left ?? string.Empty) + .Equals( + ArMms.MmsFcReferenceNormalizer.NormalizeMmsReference(right ?? string.Empty), + StringComparison.OrdinalIgnoreCase); + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private static void TryDeleteStagingRoot(string path, ICollection evidence) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + evidence.Add($"Recovery staging-directory cleanup warning: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static DynamicReportCommandFocusRequalificationAssessment AssessmentFailure( + string summary, + IReadOnlyList evidence) + => new() + { + IsSuccess = false, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportCommandFocusRequalificationResult Blocked( + string summary, + IReadOnlyList evidence, + ArMms.MmsDynamicReportQualificationProfile? originalProfile = null) + => new() + { + IsBlocked = true, + Summary = summary + " The existing live profile was not changed; ProductionEligible remains OFF.", + OriginalProfile = originalProfile, + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportCommandFocusRequalificationResult Failed( + string summary, + IReadOnlyList evidence, + ArMms.MmsDynamicReportQualificationProfile? originalProfile, + IReadOnlyList? members = null, + DynamicReportActivationCommissioningResult? activation = null, + DynamicReportCleanupClosureCommissioningResult? closure = null) + => new() + { + IsSuccess = false, + IsBlocked = false, + LiveProfileReplaced = false, + FreshCleanupClosureSucceeded = closure?.IsSuccess == true, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + OriginalProfile = originalProfile, + ActivationResult = activation, + CleanupClosureResult = closure, + QualifiedMemberReferences = members ?? Array.Empty(), + EvidenceLines = evidence.ToArray() + }; + + private sealed class StagedEnvelopeResult + { + public bool IsSuccess { get; init; } + public string Summary { get; init; } = string.Empty; + public ArMms.MmsDynamicReportQualificationProfile? Profile { get; init; } + public IReadOnlyList MemberReferences { get; init; } = Array.Empty(); + public IReadOnlyList CommandStatusReferences { get; init; } = Array.Empty(); + + public static StagedEnvelopeResult Fail(string summary) => new() { Summary = summary }; + } +} \ No newline at end of file From 843ba03583a1f2248844a425b34b89d76070c671 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 15:17:18 +0700 Subject: [PATCH 19/77] G2.6 P1: wire transactional recovery before A3 --- DynamicReportCommandBoundWitnessUiBehavior.cs | 102 ++++++++++++++++-- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs index ee90ed28..5d084372 100644 --- a/DynamicReportCommandBoundWitnessUiBehavior.cs +++ b/DynamicReportCommandBoundWitnessUiBehavior.cs @@ -98,12 +98,12 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) catch (Exception ex) { window.LastStatusText = a3 - ? "G2.6-P1 A3 stopped locally; persisted qualification and production reporting policy remain unchanged." + ? "G2.6-P1 A3 stopped locally; persisted qualification and production reporting policy remain fail-closed." : "G2.5-A2.1 V3 stopped locally; production dynamic reporting remains OFF."; MessageBox.Show( window, (a3 - ? "G2.6-P1 deterministic A3 stopped. Cleanup remains owned by the core G2.5-A transaction; this action cannot mark ProductionEligible.\n\n" + ? "G2.6-P1 deterministic A3/recovery stopped. Any recovery mutation is staging-only until full proof and atomic replacement; A3 cleanup remains owned by the core G2.5-A transaction. Neither path can mark ProductionEligible.\n\n" : "G2.5-A2.1 V3 stopped. The witness did not change production reporting policy.\n\n") + ex, title, MessageBoxButton.OK, @@ -154,11 +154,11 @@ private static async Task RunDeterministicA3Async(MainWindow window, Models.Iec6 window, $"Arm G2.6-P1 deterministic A3 command-bound dchg proof for {device.Name} ({device.EndpointText})?\n\n" + "ONE G2.4-PROVEN URCB + DCHG ONLY + ONE EXISTING ARSAS COMMAND\n\n" + - "A3 first opens a READ-ONLY witness association and refuses to arm the report path unless at least one existing ARSAS control object's A2.1 status chain intersects the exact persisted G2.4 member envelope. This avoids spending a breaker operation on a stimulus the A3 DataSet cannot prove.\n\n" + - "The core report transaction temporarily configures ONLY the exact G2.4-proven URCB with dchg enabled, GI/integrity/qchg/dupd disabled, and reason-for-inclusion + DataSet-name enabled. It creates one bounded temporary DataSet and performs mandatory monitor/field/fresh-association cleanup.\n\n" + + "A3 first performs a READ-ONLY command-focus assessment. If the existing InformationReportProven envelope already intersects an ARSAS control status chain, it proceeds normally. If field evidence shows the envelope cannot witness any command, ARSAS will OFFER a separate transactional command-focus requalification before A3; it will never silently downgrade or overwrite the proven profile.\n\n" + + "The A3 core report transaction temporarily configures ONLY the exact InformationReport-proven URCB with dchg enabled, GI/integrity/qchg/dupd disabled, and reason-for-inclusion + DataSet-name enabled. It creates one bounded temporary DataSet and performs mandatory monitor/field/fresh-association cleanup.\n\n" + "After the status shows 'G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN/CLOSE using the normal ARSAS control UI. A3 observes the existing runtime 'Control execution requested:' diagnostic; it does NOT call, wrap, delay, duplicate or re-issue ExecuteControlAsync/SBOw/Operate.\n\n" + "PASS requires the post-command read-only witness to see a transition on a qualified command-focus member AND the dchg InformationReport to include the same exact DataSet index, followed by complete cleanup.\n\n" + - "A3 never saves/advances the profile and can never mark ProductionEligible. Production automatic dynamic reporting remains OFF after this test. Do not run another G2 hotkey while A3 is armed.\n\n" + + "A3 never advances ProductionEligible. Production automatic dynamic reporting remains OFF after this test. Do not run another G2 hotkey while A3/recovery is armed.\n\n" + "Continue?", "G2.6-P1 Deterministic A3", MessageBoxButton.YesNo, @@ -167,17 +167,103 @@ private static async Task RunDeterministicA3Async(MainWindow window, Models.Iec6 if (answer != MessageBoxResult.Yes) return; - window.LastStatusText = $"G2.6-P1 A3: preflighting exact G2.4 envelope and command-bound status intersection for {device.Name}…"; + var signals = device.Signals.ToArray(); + var recovery = new DynamicReportCommandFocusRequalificationCommissioningService(); + window.LastStatusText = $"G2.6-P1 A3: READ-ONLY assessment of exact InformationReportProven envelope vs ARSAS command status for {device.Name}…"; + var assessment = await recovery.AssessAsync(device, signals, CancellationToken.None); + if (!assessment.IsSuccess) + { + window.LastStatusText = assessment.Summary; + MessageBox.Show( + window, + assessment.Summary + FormatEvidence(assessment.EvidenceLines), + "G2.6-P1 A3 Preflight Blocked", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + if (assessment.RequiresRequalification) + { + var recoverAnswer = MessageBox.Show( + window, + "FIELD-DISCOVERED COMMAND-FOCUS RECOVERY IS REQUIRED\n\n" + + assessment.Summary + "\n\n" + + "If you continue, ARSAS will:\n" + + "• issue ZERO control commands; do not press OPEN/CLOSE during recovery;\n" + + "• discover/direct-read exact ControlStatusReference + A2.1 CSWI/XCBR focus points;\n" + + "• qualify a temporary dynamic DataSet in a PRIVATE staging profile store;\n" + + "• prove one-URCB G2.4 activation + an actual InformationReport;\n" + + "• prove fresh-association RCB/DataSet cleanup closure;\n" + + "• keep the current InformationReportProven live profile untouched on ANY failure;\n" + + "• only after every stage passes, atomically replace the live profile with the new InformationReportProven command-focus profile;\n" + + "• automatically re-arm A3 afterward.\n\n" + + "ProductionEligible remains OFF. The recovery does not prove spontaneous dchg; that remains the one-command A3 test after the exact READY marker.\n\n" + + "Run transactional command-focus recovery now?", + "G2.6-P1 Transactional Recovery", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (recoverAnswer != MessageBoxResult.Yes) + { + window.LastStatusText = "G2.6-P1 A3 stopped before recovery. Existing InformationReportProven profile remains unchanged; production dynamic reporting remains OFF."; + return; + } + + var recoveryProgress = new Progress(text => window.LastStatusText = text); + var recoveryResult = await recovery.RunAsync( + device, + signals, + recoveryProgress, + CancellationToken.None); + if (!recoveryResult.IsSuccess || !recoveryResult.LiveProfileReplaced || !recoveryResult.FreshCleanupClosureSucceeded) + { + window.LastStatusText = recoveryResult.Summary; + MessageBox.Show( + window, + recoveryResult.Summary + FormatEvidence(recoveryResult.EvidenceLines), + "G2.6-P1 Recovery Did Not Close", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + window.LastStatusText = "G2.6-P1 recovery PASS. Re-running READ-ONLY command-focus assessment before automatic A3 arm…"; + var postRecovery = await recovery.AssessAsync(device, signals, CancellationToken.None); + if (!postRecovery.IsSuccess || postRecovery.RequiresRequalification) + { + window.LastStatusText = "G2.6-P1 recovery persisted, but the independent post-recovery A3 eligibility assessment did not close. Do NOT command."; + MessageBox.Show( + window, + window.LastStatusText + "\n\n" + postRecovery.Summary + FormatEvidence(postRecovery.EvidenceLines), + "G2.6-P1 Post-Recovery Gate Blocked", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + } + + window.LastStatusText = $"G2.6-P1 A3: command-focus gate passed; preparing exact dchg-only report transaction for {device.Name}. DO NOT command until the exact A3 READY marker appears…"; var progress = new Progress(text => window.LastStatusText = text); var service = new DynamicReportCommandBoundDataChangeCommissioningService(); var result = await service.RunAsync( window.A21WitnessRuntime, device, - device.Signals.ToArray(), + signals, progress, CancellationToken.None); window.LastStatusText = result.Summary; var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } -} + + private static string FormatEvidence(IReadOnlyList evidence) + { + if (evidence.Count == 0) + return string.Empty; + + var lines = evidence.Take(18).ToArray(); + var suffix = evidence.Count > lines.Length ? $"\n… ({evidence.Count - lines.Length} more evidence lines omitted)" : string.Empty; + return "\n\nEvidence:\n" + string.Join("\n", lines) + suffix; + } +} \ No newline at end of file From 30c5dd3f50905ea7e782f1dc134789cb94d16f3c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 15:17:42 +0700 Subject: [PATCH 20/77] G2.6 P1: lock transactional recovery invariants --- ...mandFocusRequalificationRegressionTests.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs diff --git a/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs b/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs new file mode 100644 index 00000000..31a22dc6 --- /dev/null +++ b/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs @@ -0,0 +1,101 @@ +namespace ARSAS.Tests; + +public sealed class G26P1CommandFocusRequalificationRegressionTests +{ + [Fact] + public void Recovery_StagesAwayFromLiveProfile_AndCommitsOnlyAfterFreshCleanupClosure() + { + var source = Read("Services/DynamicReportCommandFocusRequalificationCommissioningService.cs"); + + var stagingRoot = source.IndexOf("g26-p1-command-focus-", StringComparison.Ordinal); + var stagingStore = source.IndexOf("new DynamicReportQualificationProfileStore(stagingRoot)", StringComparison.Ordinal); + var activation = source.IndexOf("new DynamicReportActivationCommissioningServiceV2(stagingStore)", StringComparison.Ordinal); + var closure = source.IndexOf("new DynamicReportCleanupClosureCommissioningService(stagingStore)", StringComparison.Ordinal); + var closureGate = source.IndexOf("if (!closure.IsSuccess)", StringComparison.Ordinal); + var concurrencyGate = source.IndexOf("SameProfileEvidence(originalProfile, currentLoad.Profile)", StringComparison.Ordinal); + var liveSave = source.IndexOf("await _liveProfileStore.SaveAsync(finalProfile", StringComparison.Ordinal); + + Assert.True(stagingRoot >= 0); + Assert.True(stagingStore > stagingRoot); + Assert.True(activation > stagingStore); + Assert.True(closure > activation); + Assert.True(closureGate > closure); + Assert.True(concurrencyGate > closureGate); + Assert.True(liveSave > concurrencyGate); + Assert.Contains("atomic replacement", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Recovery_NeverIssuesControl_AndCannotProduceProductionEligible() + { + var source = Read("Services/DynamicReportCommandFocusRequalificationCommissioningService.cs"); + + Assert.DoesNotContain("ExecuteControlAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("profile.State == ArMms.MmsDynamicReportQualificationState.ProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("forbidden in P1 recovery", source, StringComparison.Ordinal); + Assert.Contains("ZERO control execution", source, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_RequiresExactCommandStatusMember_ToSurviveQualificationAndG24() + { + var source = Read("Services/DynamicReportCommandFocusRequalificationCommissioningService.cs"); + + Assert.Contains("ResolveCommandStatusPoints", source, StringComparison.Ordinal); + Assert.Contains("commandStatusReferences", source, StringComparison.Ordinal); + Assert.Contains("acceptedStatuses.Length == 0", source, StringComparison.Ordinal); + Assert.Contains("FinalProfileMatchesCommandFocus", source, StringComparison.Ordinal); + Assert.Contains("final G2.4 exact member sequence has no retained command-status member", source, StringComparison.Ordinal); + Assert.Contains("MaximumCommandFocusMembers = DynamicReportActivationCommissioningService.MaximumG24Members", source, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_UsesExistingG23QualificationPrimitive_AndExistingG24PhysicalProof() + { + var source = Read("Services/DynamicReportCommandFocusRequalificationCommissioningService.cs"); + + Assert.Contains("RunDynamicDataSetQualificationCommissioningAsync", source, StringComparison.Ordinal); + Assert.Contains("MmsDynamicDataSetQualificationExecutionMode.ExplicitCommissioning", source, StringComparison.Ordinal); + Assert.Contains("AcceptExactEnvelope", source, StringComparison.Ordinal); + Assert.Contains("CreateEnvelopeQualifiedProfile", source, StringComparison.Ordinal); + Assert.Contains("DynamicReportActivationCommissioningServiceV2", source, StringComparison.Ordinal); + Assert.Contains("DynamicReportCleanupClosureCommissioningService", source, StringComparison.Ordinal); + } + + [Fact] + public void A3Ui_OffersRecoveryOnlyAfterReadOnlyAssessment_ThenReassessesBeforeAutomaticArm() + { + var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); + + var assess = ui.IndexOf("recovery.AssessAsync", StringComparison.Ordinal); + var offer = ui.IndexOf("Run transactional command-focus recovery now?", StringComparison.Ordinal); + var run = ui.IndexOf("recovery.RunAsync", StringComparison.Ordinal); + var post = ui.IndexOf("postRecovery = await recovery.AssessAsync", StringComparison.Ordinal); + var a3 = ui.IndexOf("new DynamicReportCommandBoundDataChangeCommissioningService", StringComparison.Ordinal); + + Assert.True(assess >= 0); + Assert.True(offer > assess); + Assert.True(run > offer); + Assert.True(post > run); + Assert.True(a3 > post); + Assert.Contains("DO NOT command until the exact A3 READY marker appears", ui, StringComparison.Ordinal); + Assert.Contains("keep the current InformationReportProven live profile untouched on ANY failure", ui, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} \ No newline at end of file From eaa198efb7c792cd5d6c7b84dff8e017134f648e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 15:18:20 +0700 Subject: [PATCH 21/77] G2.6 P1: document transactional command-focus recovery --- docs/G2_6_P1_DETERMINISTIC_A3.md | 40 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/G2_6_P1_DETERMINISTIC_A3.md b/docs/G2_6_P1_DETERMINISTIC_A3.md index d67407a6..fe4b7347 100644 --- a/docs/G2_6_P1_DETERMINISTIC_A3.md +++ b/docs/G2_6_P1_DETERMINISTIC_A3.md @@ -27,13 +27,38 @@ Before the report path is allowed to mutate an RCB, P1 requires: 5. the A2.1 status/focus chain for that command intersects the exact G2.4-proven DataSet member sequence; 6. no control command is already busy. -If the command/status chain does not intersect the qualified DataSet, A3 stops **before** the core report transaction is started. The operator is told to re-qualify an envelope containing the relevant CSWI/XCBR status instead of spending a breaker operation on an unprovable stimulus. +The first pass is read-only. If the existing InformationReport-proven envelope already contains a command-focus member, A3 proceeds normally. + +## Field-discovered command-focus recovery + +Physical P1 testing found an important valid state that the original implementation did not recover from: the IED can already be `InformationReportProven` while its exact proven member envelope contains no CSWI/XCBR status that can witness an ARSAS command. The old instruction to “re-qualify an envelope” was a dead end because normal G2.3 intentionally refuses to downgrade an advanced profile. + +P1 now handles that state with an explicit **transactional staging recovery**. It is offered only after the read-only assessment proves that the existing envelope has zero command-focus intersection. + +The recovery contract is: + +1. keep the current live `InformationReportProven` profile untouched; +2. discover exact live `ControlStatusReference` points and the same bounded A2.1 CSWI/XCBR/XSWI focus chain; +3. direct-read validate those points; +4. run explicit dynamic NamedVariableList qualification in a private temporary profile-store root; +5. create only a staged `EnvelopeQualified` profile; +6. run the existing G2.4 V2 one-URCB activation + actual InformationReport proof against that staging store; +7. run G2.4-C on a fresh read-only association and require full RCB/DataSet cleanup closure; +8. require the final exact G2.4 member sequence still to contain at least one exact command-status member; +9. re-read the live profile and abort if its evidence changed concurrently; +10. only then atomically replace the live profile with the staged `InformationReportProven` profile. + +Any failure before step 10 leaves the previous live profile authoritative. The normal profile store already persists by temporary-file + atomic move, so a completed replacement cannot expose a partially serialized profile. + +Recovery issues **zero control commands**. The operator must not press OPEN/CLOSE while recovery is running. It also cannot call `MarkProductionEligible`; the resulting state is exactly `InformationReportProven`. + +After recovery succeeds, ARSAS performs an independent read-only command-focus assessment again. Only if that assessment closes does it automatically continue into A3. The operator still waits for the exact A3 READY marker before issuing the one physical command. ## Armed transaction The existing `DynamicReportSpontaneousDataChangeCommissioningService` remains authoritative for the report transaction: -- one exact G2.4-proven URCB; +- one exact InformationReport-proven URCB; - one bounded temporary dynamic DataSet; - `TrgOps`: dchg only; - GI disabled; @@ -52,7 +77,7 @@ A separate auxiliary MMS association is strictly read-only. It captures the fina P1 does not call or wrap `ExecuteControlAsync`. -The operator issues exactly one already-proven safe OPEN/CLOSE through the normal ARSAS control UI after this status appears: +The operator issues exactly one already-proven safe OPEN/CLOSE through the normal ARSAS control UI **only after** this status appears: `G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND` @@ -81,6 +106,11 @@ The evidence window records the command object/request, transition member/index/ The combined proof separates several useful failure classes: +- read-only assessment cannot resolve the old exact envelope -> model/profile identity problem; +- recovery DataSet qualification fails -> command-focus member / NamedVariableList capability problem; old profile remains untouched; +- staged G2.4 fails -> RCB activation or actual InformationReport problem; old profile remains untouched; +- staged G2.4-C fails -> fresh cleanup closure problem; old profile remains untouched; +- concurrency gate fails -> another qualification action changed the live evidence; recovery refuses to overwrite it; - report path never arms -> activation/configuration problem; - command is not captured -> ARSAS stimulus/capture problem; - command captured but no qualified transition -> wrong/non-changing qualified member or physical/control feedback problem; @@ -92,6 +122,6 @@ The combined proof separates several useful failure classes: A3 success is intentionally weaker than production eligibility. -P1 never calls `MarkProductionEligible`, never saves a promoted qualification profile, and never changes Smart Auto policy. After A3, the persisted field state remains `InformationReportProven` until later shadow verification and the complete G2.6 regression acceptance explicitly advance it. +The recovery path may atomically replace one `InformationReportProven` profile with another `InformationReportProven` profile after stronger command-focus staging evidence, but neither recovery nor A3 can advance to `ProductionEligible`. A3 itself remains read-only with respect to persisted profile state. Smart Auto production authorization therefore stays fail-closed until later shadow verification and the complete G2.6 regression acceptance explicitly advance the profile. -The ARIEC engine pinned by P1 already contains the P0 production consumer (PR #97), but that consumer remains fail-closed for the current field IED while the profile is below `ProductionEligible`. +The ARIEC engine pinned by P1 already contains the P0 production consumer (PR #97), but that consumer remains fail-closed for the current field IED while the profile is below `ProductionEligible`. \ No newline at end of file From 2eff80ee2b44bf04ab9bcdd5f84e3d20c2b18f94 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 15:20:17 +0700 Subject: [PATCH 22/77] Fix P1 recovery evidence collection build --- .../DynamicReportEvidenceCollectionExtensions.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 Services/DynamicReportEvidenceCollectionExtensions.cs diff --git a/Services/DynamicReportEvidenceCollectionExtensions.cs b/Services/DynamicReportEvidenceCollectionExtensions.cs new file mode 100644 index 00000000..6a21ab01 --- /dev/null +++ b/Services/DynamicReportEvidenceCollectionExtensions.cs @@ -0,0 +1,16 @@ +namespace ArIED61850Tester.Services; + +/// +/// Keeps commissioning evidence helpers usable with the ICollection contract used by +/// staged recovery routines without forcing callers to expose a concrete List type. +/// +internal static class DynamicReportEvidenceCollectionExtensions +{ + internal static void AddRange(this ICollection target, IEnumerable values) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(values); + foreach (var value in values) + target.Add(value); + } +} From a04a1a8591009ca269a5ffa7b698f369d426c6cf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 16:52:31 +0700 Subject: [PATCH 23/77] G2.6 P1: add Q0 target-locked one-shot auto stimulus --- ...0TargetLockedAutoA3CommissioningService.cs | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs diff --git a/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs b/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs new file mode 100644 index 00000000..03de11c8 --- /dev/null +++ b/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs @@ -0,0 +1,335 @@ +using System.Reflection; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Field-bounded G2.6-P1 coordinator for the already-proven AA1C1F08R4 Q0 CSWI1.Pos +/// control object. Ctrl+Shift+A is the explicit commissioning action; after every +/// identity/profile/control/report gate closes, this coordinator dispatches exactly one +/// OPEN through the existing Iec61850MonitorRuntime control path. It never retries, +/// toggles, sends CLOSE, or restores the breaker automatically. +/// +/// The existing deterministic A3 service remains authoritative for the dchg-only report +/// transaction and exact DataSet-index correlation. This coordinator only removes the +/// operator timing race and target-selection ambiguity discovered during physical P1. +/// +internal sealed class DynamicReportQ0TargetLockedAutoA3CommissioningService +{ + internal const string ExpectedStableIdentity = "ied:AA1C1F08R4"; + internal const string ExpectedModelFingerprint = "sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9"; + internal const string TargetControlReference = "AA1C1F08R4Q0/CSWI1.Pos"; + internal const string TargetStatusReference = "AA1C1F08R4Q0/CSWI1.Pos.stVal"; + internal const string AutoStimulusValue = "Open"; + + private const string AutoOriginator = "ARSAS-G2.6-P1-A3"; + private const string AutoOriginCategory = "StationControl"; + + public async Task RunAsync( + Iec61850MonitorRuntime runtime, + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + if (!identity.StableIdentityKey.Equals(ExpectedStableIdentity, StringComparison.OrdinalIgnoreCase) || + !identity.ModelFingerprint.Equals(ExpectedModelFingerprint, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Q0 target-locked A3 is field-bounded to {ExpectedStableIdentity} / {ExpectedModelFingerprint}. " + + $"Connected identity is {identity.StableIdentityKey} / {identity.ModelFingerprint}. No control command was sent."); + } + + var target = fullModelSignals.SingleOrDefault(signal => + SameUserReference(signal.ObjectReference, TargetControlReference)); + if (target is null) + throw new InvalidOperationException($"Exact A3 target {TargetControlReference} is absent from the live model. No control command was sent."); + if (!target.IsControlSignal) + throw new InvalidOperationException($"Exact A3 target {TargetControlReference} is not a live ARSAS control signal. No control command was sent."); + if (!SameUserReference(target.ControlStatusReference, TargetStatusReference)) + { + throw new InvalidOperationException( + $"Exact A3 target status mismatch. Expected {TargetStatusReference}; live ControlStatusReference={TextOrDash(target.ControlStatusReference)}. No control command was sent."); + } + if (target.ControlCommandBusy) + throw new InvalidOperationException($"Exact A3 target {TargetControlReference} is already busy. No additional control command was sent."); + + progress?.Report($"G2.6-P1 Q0 AUTO: target locked to {TargetControlReference}; validating existing ARSAS control semantics before any A3 report mutation…"); + await RequireClosedOperationalTargetAsync(runtime, device, target, "initial preflight", cancellationToken).ConfigureAwait(false); + + // Existing field recovery is intentionally reused, but with a cloned model whose + // command-focus surface exposes only Q0. Identity-significant signal properties are + // unchanged, so the exact persisted profile remains identity-compatible. Originals + // are never mutated and the normal ARSAS command panel/runtime keep their full model. + var recoverySignals = CreateTargetScopedRecoveryModel(fullModelSignals); + var scopedIdentity = DynamicReportQualificationIdentity.Build(device, recoverySignals); + if (!scopedIdentity.StableIdentityKey.Equals(identity.StableIdentityKey, StringComparison.OrdinalIgnoreCase) || + !scopedIdentity.ModelFingerprint.Equals(identity.ModelFingerprint, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Target-scoped recovery changed identity-significant model evidence. Recovery and control were blocked."); + } + + var recovery = new DynamicReportCommandFocusRequalificationCommissioningService(); + progress?.Report($"G2.6-P1 Q0 AUTO: READ-ONLY exact-profile assessment for {TargetStatusReference}…"); + var assessment = await recovery.AssessAsync(device, recoverySignals, cancellationToken).ConfigureAwait(false); + if (!assessment.IsSuccess) + throw new InvalidOperationException(assessment.Summary + " No control command was sent."); + + if (assessment.RequiresRequalification) + { + progress?.Report("G2.6-P1 Q0 AUTO: Q0 is absent from the exact G2.4 envelope; running transactional staging recovery automatically. ZERO control commands are permitted during recovery…"); + var recoveryResult = await recovery.RunAsync( + device, + recoverySignals, + progress, + cancellationToken).ConfigureAwait(false); + if (!recoveryResult.IsSuccess || !recoveryResult.LiveProfileReplaced || !recoveryResult.FreshCleanupClosureSucceeded) + { + throw new InvalidOperationException( + recoveryResult.Summary + " The previous live profile remains authoritative and no control command was sent."); + } + + progress?.Report("G2.6-P1 Q0 AUTO: staged recovery PASS; independently re-checking the exact Q0 command-focus invariant…"); + var postRecovery = await recovery.AssessAsync(device, recoverySignals, cancellationToken).ConfigureAwait(false); + if (!postRecovery.IsSuccess || postRecovery.RequiresRequalification) + { + throw new InvalidOperationException( + "Q0 recovery completed, but the independent post-recovery exact-target assessment did not close. No control command was sent. " + + postRecovery.Summary); + } + } + + // Re-read immediately before the mutating report transaction. OPEN is permitted + // only from an exact Closed state; Open/intermediate/unknown never causes a toggle. + await RequireClosedOperationalTargetAsync(runtime, device, target, "post-recovery pre-arm", cancellationToken).ConfigureAwait(false); + + using var a3Cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task? autoCommandTask = null; + Exception? readyGateFailure = null; + var autoDispatchStarted = 0; + + var immediateProgress = new ImmediateProgress(text => + { + if (!text.StartsWith(DynamicReportCommandBoundDataChangeCommissioningService.ReadyMarker, StringComparison.Ordinal)) + { + progress?.Report(text); + return; + } + + if (Interlocked.CompareExchange(ref autoDispatchStarted, 1, 0) != 0) + return; + + progress?.Report($"G2.6-P1 A3 AUTO READY — exact target {TargetControlReference}; re-validating Closed then dispatching ONE OPEN through the existing ARSAS control path. Do not press OPEN/CLOSE manually."); + autoCommandTask = DispatchOneShotOpenAsync( + runtime, + device, + target, + progress, + ex => + { + readyGateFailure = ex; + try + { + a3Cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // The A3 transaction already closed; no retry is ever attempted. + } + }, + a3Cancellation.Token); + }); + + progress?.Report("G2.6-P1 Q0 AUTO: Q0 command-focus gate closed; arming the existing dchg-only A3 report transaction. The one-shot OPEN will be dispatched only after the final read-only baseline is ready…"); + var a3 = new DynamicReportCommandBoundDataChangeCommissioningService(); + DynamicReportCommandBoundA3CommissioningResult result; + try + { + result = await a3.RunAsync( + runtime, + device, + fullModelSignals, + immediateProgress, + a3Cancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (readyGateFailure is not null && !cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "Q0 auto-stimulus READY gate failed before control dispatch. A3 was cancelled so it would not wait for a command that was deliberately blocked. No retry was attempted.", + readyGateFailure); + } + + if (autoCommandTask is not null) + { + try + { + var command = await autoCommandTask.ConfigureAwait(false); + progress?.Report( + $"G2.6-P1 Q0 AUTO command completed: success={command.IsSuccess}; accepted={command.ServiceAccepted}; feedback={command.FeedbackConfirmed}; termination={command.CommandTerminationReceived}/{command.PositiveTermination}; stage={command.Stage}. No retry, CLOSE, toggle, or auto-restore will be issued."); + } + catch (OperationCanceledException) when (a3Cancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + progress?.Report("G2.6-P1 Q0 AUTO command task was cancelled by the fail-closed A3 coordinator. No retry was attempted."); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + // Runtime wire evidence plus the A3 physical transition/report correlation + // remain authoritative. Never retry an ambiguous physical command. + progress?.Report($"G2.6-P1 Q0 AUTO command returned {ex.GetType().Name}: {ex.Message}. No retry was attempted; A3 evidence remains fail-closed."); + } + } + else if (!result.IsBlocked) + { + progress?.Report("G2.6-P1 Q0 AUTO: A3 never reached its final READY handoff, therefore zero control commands were sent."); + } + + return result; + } + + private static async Task DispatchOneShotOpenAsync( + Iec61850MonitorRuntime runtime, + Iec61850MonitorDevice device, + SignalDefinition target, + IProgress? progress, + Action failBeforeDispatch, + CancellationToken cancellationToken) + { + // Re-inspect after the A3 final witness baseline. This closes the time-of-check / + // time-of-use gap: the service never turns "current state" into an automatic toggle. + Iec61850ControlCapabilities capabilities; + try + { + capabilities = await runtime.InspectControlAsync(device.DeviceId, target, cancellationToken).ConfigureAwait(false); + ValidateClosedOperationalTarget(capabilities, "A3 READY recheck"); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + failBeforeDispatch(ex); + return; + } + + if (target.ControlCommandBusy) + { + failBeforeDispatch(new InvalidOperationException($"Exact A3 target {TargetControlReference} became busy at READY. No control command was sent.")); + return; + } + + var request = new Iec61850ControlCommandRequest + { + Signal = target, + ValueText = AutoStimulusValue, + InterlockCheck = true, + SynchroCheck = false, + TestMode = false, + Originator = AutoOriginator, + OriginCategory = AutoOriginCategory, + FeedbackTimeoutMs = 12000, + CommandTerminationTimeoutMs = 10000 + }; + + progress?.Report($"G2.6-P1 Q0 AUTO DISPATCH: {TargetControlReference} -> {AutoStimulusValue}; interlock=true; synchro=false; test=false; one-shot=true; retry=false."); + + // IMPORTANT: call the existing runtime method directly. Its already-existing + // "Control execution requested:" diagnostic is emitted synchronously before the + // native ARIEC control await, so the armed A3 witness captures the exact request. + // No separate SBO/SBOw/Operate implementation exists here. + await runtime.ExecuteControlAsync(device.DeviceId, request, cancellationToken).ConfigureAwait(false); + } + + private static async Task RequireClosedOperationalTargetAsync( + Iec61850MonitorRuntime runtime, + Iec61850MonitorDevice device, + SignalDefinition target, + string phase, + CancellationToken cancellationToken) + { + var capabilities = await runtime.InspectControlAsync(device.DeviceId, target, cancellationToken).ConfigureAwait(false); + ValidateClosedOperationalTarget(capabilities, phase); + } + + private static void ValidateClosedOperationalTarget(Iec61850ControlCapabilities capabilities, string phase) + { + if (!SameUserReference(capabilities.ObjectReference, TargetControlReference)) + { + throw new InvalidOperationException( + $"{phase}: control inspection returned {TextOrDash(capabilities.ObjectReference)} instead of exact target {TargetControlReference}. No control command was sent."); + } + + if (!capabilities.SupportsOperate || !capabilities.IsOperationallyReady) + { + throw new InvalidOperationException( + $"{phase}: exact target is not operationally ready for the existing ARSAS control service; model={capabilities.ControlModelText}. No control command was sent."); + } + + if (!capabilities.CurrentState.Equals("Closed", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{phase}: exact target must be Closed before the one-shot OPEN stimulus. CurrentState={TextOrDash(capabilities.CurrentState)}, CurrentValue={TextOrDash(capabilities.CurrentValue)}. No CLOSE/toggle/restore command is allowed."); + } + } + + private static SignalDefinition[] CreateTargetScopedRecoveryModel(IReadOnlyList fullModelSignals) + { + var cloneMethod = typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("MemberwiseClone is unavailable; target-scoped recovery cannot be isolated safely."); + var statusProperty = typeof(SignalDefinition).GetProperty( + nameof(SignalDefinition.ControlStatusReference), + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SignalDefinition.ControlStatusReference is unavailable; target-scoped recovery cannot be isolated safely."); + var statusSetter = statusProperty.GetSetMethod(nonPublic: true); + var backingField = typeof(SignalDefinition).GetField( + $"<{nameof(SignalDefinition.ControlStatusReference)}>k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic); + if (statusSetter is null && backingField is null) + throw new InvalidOperationException("ControlStatusReference cannot be changed on a private clone; target-scoped recovery was blocked."); + + var clones = new SignalDefinition[fullModelSignals.Count]; + for (var index = 0; index < fullModelSignals.Count; index++) + { + var clone = (SignalDefinition)(cloneMethod.Invoke(fullModelSignals[index], null) + ?? throw new InvalidOperationException("Signal clone failed; target-scoped recovery was blocked.")); + + if (clone.IsControlSignal && + !SameUserReference(clone.ObjectReference, TargetControlReference) && + !string.IsNullOrWhiteSpace(clone.ControlStatusReference)) + { + if (statusSetter is not null) + statusSetter.Invoke(clone, [string.Empty]); + else + backingField!.SetValue(clone, string.Empty); + } + + clones[index] = clone; + } + + var scopedCommands = clones + .Where(signal => signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + .ToArray(); + if (scopedCommands.Length != 1 || !SameUserReference(scopedCommands[0].ObjectReference, TargetControlReference)) + { + throw new InvalidOperationException( + $"Target-scoped recovery model must expose exactly one control focus ({TargetControlReference}); resolved={string.Join(", ", scopedCommands.Select(signal => signal.ObjectReference))}. No recovery/control mutation was attempted."); + } + + return clones; + } + + private static bool SameUserReference(string? left, string? right) + => NormalizeUserReference(left).Equals(NormalizeUserReference(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeUserReference(string? value) + => (value ?? string.Empty).Trim().Replace('$', '.'); + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private sealed class ImmediateProgress(Action callback) : IProgress + { + public void Report(string value) => callback(value ?? string.Empty); + } +} \ No newline at end of file From 1f7cf7e1e174b59b00ac3a47c8ea395bef361676 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 16:53:00 +0700 Subject: [PATCH 24/77] G2.6 P1: run Q0 auto A3 without command dialogs --- DynamicReportCommandBoundWitnessUiBehavior.cs | 123 ++---------------- 1 file changed, 14 insertions(+), 109 deletions(-) diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs index 5d084372..67075e21 100644 --- a/DynamicReportCommandBoundWitnessUiBehavior.cs +++ b/DynamicReportCommandBoundWitnessUiBehavior.cs @@ -63,13 +63,13 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) e.Handled = true; var device = window.SelectedDevice; var a3 = e.Key == Key.A; - var title = a3 ? "G2.6-P1 Deterministic A3" : "G2.5-A2.1 Command-Bound Witness"; + var title = a3 ? "G2.6-P1 Q0 Target-Locked Auto A3" : "G2.5-A2.1 Command-Bound Witness"; if (device is null) { MessageBox.Show( window, a3 - ? "Select one IEC 61850 IED first. Deterministic A3 is intentionally bound to one explicit IED, its exact persisted G2.4 envelope, and one explicit existing ARSAS command." + ? "Select the qualified AA1C1F08R4 IEC 61850 IED first. Q0 Auto A3 is hard-bound to the exact proven field identity and AA1C1F08R4Q0/CSWI1.Pos." : "Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.", title, MessageBoxButton.OK, @@ -98,12 +98,12 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) catch (Exception ex) { window.LastStatusText = a3 - ? "G2.6-P1 A3 stopped locally; persisted qualification and production reporting policy remain fail-closed." + ? "G2.6-P1 Q0 Auto A3 stopped fail-closed. No retry/CLOSE/toggle is issued; ProductionEligible remains OFF." : "G2.5-A2.1 V3 stopped locally; production dynamic reporting remains OFF."; MessageBox.Show( window, (a3 - ? "G2.6-P1 deterministic A3/recovery stopped. Any recovery mutation is staging-only until full proof and atomic replacement; A3 cleanup remains owned by the core G2.5-A transaction. Neither path can mark ProductionEligible.\n\n" + ? "G2.6-P1 Q0 target-locked Auto A3 stopped. Ctrl+Shift+A is the explicit commissioning action, but the one-shot OPEN is dispatched only after exact identity, Q0 status, Closed-state, command-focus, dchg-arm and final-baseline gates close. A blocked/ambiguous command is never retried and no CLOSE/toggle/auto-restore is issued. Recovery remains transactional and A3 cleanup remains mandatory. ProductionEligible stays OFF.\n\n" : "G2.5-A2.1 V3 stopped. The witness did not change production reporting policy.\n\n") + ex, title, MessageBoxButton.OK, @@ -150,120 +150,25 @@ private static async Task RunA21Async(MainWindow window, Models.Iec61850MonitorD private static async Task RunDeterministicA3Async(MainWindow window, Models.Iec61850MonitorDevice device) { - var answer = MessageBox.Show( - window, - $"Arm G2.6-P1 deterministic A3 command-bound dchg proof for {device.Name} ({device.EndpointText})?\n\n" + - "ONE G2.4-PROVEN URCB + DCHG ONLY + ONE EXISTING ARSAS COMMAND\n\n" + - "A3 first performs a READ-ONLY command-focus assessment. If the existing InformationReportProven envelope already intersects an ARSAS control status chain, it proceeds normally. If field evidence shows the envelope cannot witness any command, ARSAS will OFFER a separate transactional command-focus requalification before A3; it will never silently downgrade or overwrite the proven profile.\n\n" + - "The A3 core report transaction temporarily configures ONLY the exact InformationReport-proven URCB with dchg enabled, GI/integrity/qchg/dupd disabled, and reason-for-inclusion + DataSet-name enabled. It creates one bounded temporary DataSet and performs mandatory monitor/field/fresh-association cleanup.\n\n" + - "After the status shows 'G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN/CLOSE using the normal ARSAS control UI. A3 observes the existing runtime 'Control execution requested:' diagnostic; it does NOT call, wrap, delay, duplicate or re-issue ExecuteControlAsync/SBOw/Operate.\n\n" + - "PASS requires the post-command read-only witness to see a transition on a qualified command-focus member AND the dchg InformationReport to include the same exact DataSet index, followed by complete cleanup.\n\n" + - "A3 never advances ProductionEligible. Production automatic dynamic reporting remains OFF after this test. Do not run another G2 hotkey while A3/recovery is armed.\n\n" + - "Continue?", - "G2.6-P1 Deterministic A3", - MessageBoxButton.YesNo, - MessageBoxImage.Warning, - MessageBoxResult.No); - if (answer != MessageBoxResult.Yes) - return; + // Ctrl+Shift+A itself is the explicit commissioning action. There are deliberately + // no modal arm/recovery/command dialogs in the successful path: the coordinator is + // hard-bound to the already-proven field identity and Q0 CSWI1.Pos, performs all + // read-only/transactional gates first, and dispatches one OPEN only after the A3 + // final baseline is ready. Any failed gate sends zero commands. + window.LastStatusText = + $"G2.6-P1 Q0 AUTO starting for {device.Name}: exact target {DynamicReportQ0TargetLockedAutoA3CommissioningService.TargetControlReference}; one-shot OPEN only from Closed; no retry/CLOSE/toggle/auto-restore…"; - var signals = device.Signals.ToArray(); - var recovery = new DynamicReportCommandFocusRequalificationCommissioningService(); - window.LastStatusText = $"G2.6-P1 A3: READ-ONLY assessment of exact InformationReportProven envelope vs ARSAS command status for {device.Name}…"; - var assessment = await recovery.AssessAsync(device, signals, CancellationToken.None); - if (!assessment.IsSuccess) - { - window.LastStatusText = assessment.Summary; - MessageBox.Show( - window, - assessment.Summary + FormatEvidence(assessment.EvidenceLines), - "G2.6-P1 A3 Preflight Blocked", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - - if (assessment.RequiresRequalification) - { - var recoverAnswer = MessageBox.Show( - window, - "FIELD-DISCOVERED COMMAND-FOCUS RECOVERY IS REQUIRED\n\n" + - assessment.Summary + "\n\n" + - "If you continue, ARSAS will:\n" + - "• issue ZERO control commands; do not press OPEN/CLOSE during recovery;\n" + - "• discover/direct-read exact ControlStatusReference + A2.1 CSWI/XCBR focus points;\n" + - "• qualify a temporary dynamic DataSet in a PRIVATE staging profile store;\n" + - "• prove one-URCB G2.4 activation + an actual InformationReport;\n" + - "• prove fresh-association RCB/DataSet cleanup closure;\n" + - "• keep the current InformationReportProven live profile untouched on ANY failure;\n" + - "• only after every stage passes, atomically replace the live profile with the new InformationReportProven command-focus profile;\n" + - "• automatically re-arm A3 afterward.\n\n" + - "ProductionEligible remains OFF. The recovery does not prove spontaneous dchg; that remains the one-command A3 test after the exact READY marker.\n\n" + - "Run transactional command-focus recovery now?", - "G2.6-P1 Transactional Recovery", - MessageBoxButton.YesNo, - MessageBoxImage.Warning, - MessageBoxResult.No); - if (recoverAnswer != MessageBoxResult.Yes) - { - window.LastStatusText = "G2.6-P1 A3 stopped before recovery. Existing InformationReportProven profile remains unchanged; production dynamic reporting remains OFF."; - return; - } - - var recoveryProgress = new Progress(text => window.LastStatusText = text); - var recoveryResult = await recovery.RunAsync( - device, - signals, - recoveryProgress, - CancellationToken.None); - if (!recoveryResult.IsSuccess || !recoveryResult.LiveProfileReplaced || !recoveryResult.FreshCleanupClosureSucceeded) - { - window.LastStatusText = recoveryResult.Summary; - MessageBox.Show( - window, - recoveryResult.Summary + FormatEvidence(recoveryResult.EvidenceLines), - "G2.6-P1 Recovery Did Not Close", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - - window.LastStatusText = "G2.6-P1 recovery PASS. Re-running READ-ONLY command-focus assessment before automatic A3 arm…"; - var postRecovery = await recovery.AssessAsync(device, signals, CancellationToken.None); - if (!postRecovery.IsSuccess || postRecovery.RequiresRequalification) - { - window.LastStatusText = "G2.6-P1 recovery persisted, but the independent post-recovery A3 eligibility assessment did not close. Do NOT command."; - MessageBox.Show( - window, - window.LastStatusText + "\n\n" + postRecovery.Summary + FormatEvidence(postRecovery.EvidenceLines), - "G2.6-P1 Post-Recovery Gate Blocked", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - } - - window.LastStatusText = $"G2.6-P1 A3: command-focus gate passed; preparing exact dchg-only report transaction for {device.Name}. DO NOT command until the exact A3 READY marker appears…"; var progress = new Progress(text => window.LastStatusText = text); - var service = new DynamicReportCommandBoundDataChangeCommissioningService(); + var service = new DynamicReportQ0TargetLockedAutoA3CommissioningService(); var result = await service.RunAsync( window.A21WitnessRuntime, device, - signals, + device.Signals.ToArray(), progress, CancellationToken.None); + window.LastStatusText = result.Summary; var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } - - private static string FormatEvidence(IReadOnlyList evidence) - { - if (evidence.Count == 0) - return string.Empty; - - var lines = evidence.Take(18).ToArray(); - var suffix = evidence.Count > lines.Length ? $"\n… ({evidence.Count - lines.Length} more evidence lines omitted)" : string.Empty; - return "\n\nEvidence:\n" + string.Join("\n", lines) + suffix; - } } \ No newline at end of file From 6db7b7dd708c069bb331f3d6f90d2703bb842516 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 16:53:34 +0700 Subject: [PATCH 25/77] G2.6 P1: lock regression contract for Q0 auto stimulus --- .../G26P1DeterministicA3RegressionTests.cs | 81 ++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index 1fda0b85..ffa9de01 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -3,7 +3,7 @@ namespace ARSAS.Tests; public sealed class G26P1DeterministicA3RegressionTests { [Fact] - public void A3_ObservesExistingRuntimeCommand_AndNeverExecutesControlItself() + public void A3_CoreStillObservesRuntimeCommand_AndNeverExecutesControlItself() { var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); @@ -64,24 +64,87 @@ public void A3_ReusesStrictDchgOnlyCoreAndMandatoryCleanup() public void A3_CannotAdvanceProductionEligibility() { var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); var evidenceWindow = Read("DynamicReportQualificationResultWindow.G26P1A3.cs"); Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); Assert.DoesNotContain("SaveAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", auto, StringComparison.Ordinal); Assert.Contains("profile remains InformationReportProven", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("A3 command-bound dchg PASS != ProductionEligible", evidenceWindow, StringComparison.Ordinal); Assert.Contains("Production automatic dynamic reporting remains OFF", evidenceWindow, StringComparison.Ordinal); } [Fact] - public void A3_HasSeparateExplicitHotkeyFromA21Witness() + public void A3_HasSeparateExplicitHotkeyFromA21Witness_AndUsesQ0AutoCoordinator() { var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); Assert.Contains("(e.Key != Key.F && e.Key != Key.A)", ui, StringComparison.Ordinal); Assert.Contains("var a3 = e.Key == Key.A", ui, StringComparison.Ordinal); - Assert.Contains("DynamicReportCommandBoundDataChangeCommissioningService", ui, StringComparison.Ordinal); + Assert.Contains("DynamicReportQ0TargetLockedAutoA3CommissioningService", ui, StringComparison.Ordinal); Assert.Contains("G2.6-P1 A3 READY", Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"), StringComparison.Ordinal); + Assert.DoesNotContain("Arm G2.6-P1 deterministic A3", ui, StringComparison.Ordinal); + Assert.DoesNotContain("G2.6-P1 Transactional Recovery\"", ui, StringComparison.Ordinal); + } + + [Fact] + public void Q0AutoA3_IsHardBoundToExactFieldIdentityControlStatusAndOpenStimulus() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + + Assert.Contains("ExpectedStableIdentity = \"ied:AA1C1F08R4\"", auto, StringComparison.Ordinal); + Assert.Contains("sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9", auto, StringComparison.OrdinalIgnoreCase); + Assert.Contains("TargetControlReference = \"AA1C1F08R4Q0/CSWI1.Pos\"", auto, StringComparison.Ordinal); + Assert.Contains("TargetStatusReference = \"AA1C1F08R4Q0/CSWI1.Pos.stVal\"", auto, StringComparison.Ordinal); + Assert.Contains("AutoStimulusValue = \"Open\"", auto, StringComparison.Ordinal); + Assert.Contains("CurrentState.Equals(\"Closed\"", auto, StringComparison.Ordinal); + } + + [Fact] + public void Q0AutoA3_UsesExistingRuntimeControlPathExactlyOnceWithoutToggleRetryOrClose() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + + Assert.Contains("Interlocked.CompareExchange(ref autoDispatchStarted, 1, 0)", auto, StringComparison.Ordinal); + Assert.Contains("runtime.ExecuteControlAsync(device.DeviceId, request, cancellationToken)", auto, StringComparison.Ordinal); + Assert.Contains("InterlockCheck = true", auto, StringComparison.Ordinal); + Assert.Contains("SynchroCheck = false", auto, StringComparison.Ordinal); + Assert.Contains("TestMode = false", auto, StringComparison.Ordinal); + Assert.Contains("retry=false", auto, StringComparison.OrdinalIgnoreCase); + Assert.Contains("No CLOSE/toggle/restore command is allowed", auto, StringComparison.Ordinal); + Assert.DoesNotContain("ValueText = \"Close\"", auto, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, CountOccurrences(auto, "runtime.ExecuteControlAsync(")); + } + + [Fact] + public void Q0AutoA3_RechecksClosedStateAfterFinalA3ReadyBeforeDispatch() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + + var readyIntercept = auto.IndexOf("ReadyMarker", StringComparison.Ordinal); + var dispatch = auto.IndexOf("DispatchOneShotOpenAsync", readyIntercept, StringComparison.Ordinal); + var readyRecheck = auto.IndexOf("A3 READY recheck", dispatch, StringComparison.Ordinal); + var execute = auto.IndexOf("runtime.ExecuteControlAsync", dispatch, StringComparison.Ordinal); + + Assert.True(readyIntercept >= 0); + Assert.True(dispatch > readyIntercept); + Assert.True(readyRecheck > dispatch); + Assert.True(execute > readyRecheck); + } + + [Fact] + public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIdentityEvidence() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + var identity = Read("Services/DynamicReportQualificationIdentity.cs"); + + Assert.Contains("MemberwiseClone", auto, StringComparison.Ordinal); + Assert.Contains("CreateTargetScopedRecoveryModel", auto, StringComparison.Ordinal); + Assert.Contains("statusSetter.Invoke(clone, [string.Empty])", auto, StringComparison.Ordinal); + Assert.Contains("scopedCommands.Length != 1", auto, StringComparison.Ordinal); + Assert.Contains("DynamicReportQualificationIdentity.Build(device, recoverySignals)", auto, StringComparison.Ordinal); + Assert.DoesNotContain("ControlStatusReference", identity, StringComparison.Ordinal); } [Fact] @@ -96,6 +159,18 @@ public void EngineLock_PinsMergedProductionConsumerButKeepsCurrentFieldStateLock Assert.Contains("production automatic dynamic reporting remains OFF", engineLock, StringComparison.OrdinalIgnoreCase); } + private static int CountOccurrences(string source, string value) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(value, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + return count; + } + private static string Read(string relativePath) => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); From 6ce113d7422c529a1bb81cbfa3df0da4b3aa2670 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 16:54:23 +0700 Subject: [PATCH 26/77] G2.6 P1: document target-locked Q0 auto A3 --- docs/G2_6_P1_DETERMINISTIC_A3.md | 146 +++++++++++++++++++------------ 1 file changed, 91 insertions(+), 55 deletions(-) diff --git a/docs/G2_6_P1_DETERMINISTIC_A3.md b/docs/G2_6_P1_DETERMINISTIC_A3.md index fe4b7347..0ea65022 100644 --- a/docs/G2_6_P1_DETERMINISTIC_A3.md +++ b/docs/G2_6_P1_DETERMINISTIC_A3.md @@ -1,62 +1,80 @@ -# G2.6-P1 — Deterministic Command-Bound A3 dchg Proof +# G2.6-P1 — Deterministic Q0 Target-Locked Auto A3 dchg Proof ## Goal -Convert the previous generic/manual G2.5-A dchg stimulus into one deterministic ARSAS-owned evidence chain: +Close the field A3 proof with one exact, already-proven ARSAS control path and remove both sources of physical-test ambiguity discovered during P1: -`existing ARSAS control command -> qualified MMS status transition -> Dynamic URCB InformationReport(reason=data-change) -> cleanup` +1. generic command-focus recovery selected the first eight alphabetically ordered control-status members and excluded the intended Q0 control; +2. manual READY → operator click timing could expire without any command being captured. -This is a commissioning proof only. It does **not** mark an IED `ProductionEligible` and it does **not** enable production automatic dynamic reporting. +The field-bounded P1 chain is now: + +`AA1C1F08R4Q0/CSWI1.Pos one-shot OPEN -> qualified Q0 MMS status transition -> Dynamic URCB InformationReport(reason=data-change) on the same DataSet index -> cleanup` + +This is a commissioning proof only. It does **not** mark the IED `ProductionEligible` and it does **not** enable production automatic dynamic reporting. ## Entry point -Select the target IEC 61850 IED in ARSAS, then press: +Select the qualified field IED in ARSAS, then press: `Ctrl + Shift + A` -The older A2.1 read-only command witness remains available separately on `Ctrl + Shift + F`. +For this P1 field build, the hotkey itself is the explicit commissioning action. There is no successful-path arm dialog, recovery dialog, or manual command dialog. The older A2.1 read-only/manual command witness remains separately available on `Ctrl + Shift + F`. -## Preflight gates +## Exact field lock -Before the report path is allowed to mutate an RCB, P1 requires: +Auto A3 is deliberately bounded to all of the following exact values: + +- stable identity: `ied:AA1C1F08R4`; +- model fingerprint: `sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9`; +- control object: `AA1C1F08R4Q0/CSWI1.Pos`; +- control status: `AA1C1F08R4Q0/CSWI1.Pos.stVal`; +- stimulus: `Open` only; +- interlock check: enabled; +- synchrocheck: disabled; +- test mode: disabled. -1. the persisted profile is identity-compatible and exactly `InformationReportProven`; -2. the G2.4 RCB activation proof and InformationReport proof are successful; -3. the exact G2.4 member sequence still resolves on the live IED; -4. at least one existing ARSAS control object exposes an exact `ControlStatusReference`; -5. the A2.1 status/focus chain for that command intersects the exact G2.4-proven DataSet member sequence; -6. no control command is already busy. +The coordinator never converts the current state into a toggle. `Open`, intermediate, unknown, wrong identity, wrong status mapping, busy control, or a non-operational control model all block command dispatch. There is no automatic CLOSE, retry, opposite command, or restore command. -The first pass is read-only. If the existing InformationReport-proven envelope already contains a command-focus member, A3 proceeds normally. +## Preflight gates -## Field-discovered command-focus recovery +Before the report path is allowed to mutate an RCB, P1 requires: -Physical P1 testing found an important valid state that the original implementation did not recover from: the IED can already be `InformationReportProven` while its exact proven member envelope contains no CSWI/XCBR status that can witness an ARSAS command. The old instruction to “re-qualify an envelope” was a dead end because normal G2.3 intentionally refuses to downgrade an advanced profile. +1. the connected model resolves to the exact field identity and model fingerprint above; +2. the exact Q0 control object exists and exposes the exact `ControlStatusReference` above; +3. the existing ARSAS control inspector reports the exact target operationally ready; +4. the exact target state is `Closed` before recovery/report arming; +5. the persisted profile is identity-compatible and exactly `InformationReportProven`; +6. the G2.4 RCB activation proof and InformationReport proof are successful; +7. the exact G2.4 member sequence still resolves on the live IED; +8. the Q0 A2.1 status/focus chain intersects the exact G2.4-proven DataSet member sequence; +9. no control command is already busy. -P1 now handles that state with an explicit **transactional staging recovery**. It is offered only after the read-only assessment proves that the existing envelope has zero command-focus intersection. +The control state is checked again after any recovery and once more after the A3 final witness baseline is ready. The one-shot OPEN is dispatched only if that final READY-time inspection still says exactly `Closed`. -The recovery contract is: +## Field-discovered Q0 command-focus recovery -1. keep the current live `InformationReportProven` profile untouched; -2. discover exact live `ControlStatusReference` points and the same bounded A2.1 CSWI/XCBR/XSWI focus chain; -3. direct-read validate those points; -4. run explicit dynamic NamedVariableList qualification in a private temporary profile-store root; -5. create only a staged `EnvelopeQualified` profile; -6. run the existing G2.4 V2 one-URCB activation + actual InformationReport proof against that staging store; -7. run G2.4-C on a fresh read-only association and require full RCB/DataSet cleanup closure; -8. require the final exact G2.4 member sequence still to contain at least one exact command-status member; -9. re-read the live profile and abort if its evidence changed concurrently; -10. only then atomically replace the live profile with the staged `InformationReportProven` profile. +Physical P1 testing proved that the IED could already be `InformationReportProven` while the exact proven member envelope contained command statuses for DSQZ/ESQZ objects but not the intended `AA1C1F08R4Q0/CSWI1.Pos.stVal`. The previous generic recovery sorted all ARSAS commands by object reference and the eight-member cap was exhausted before Q0 was reached. -Any failure before step 10 leaves the previous live profile authoritative. The normal profile store already persists by temporary-file + atomic move, so a completed replacement cannot expose a partially serialized profile. +P1 now reuses the transactional recovery with a **private target-scoped clone of the discovered signal model**: -Recovery issues **zero control commands**. The operator must not press OPEN/CLOSE while recovery is running. It also cannot call `MarkProductionEligible`; the resulting state is exactly `InformationReportProven`. +1. the normal live `SignalDefinition` instances are never modified; +2. every signal is privately shallow-cloned; +3. only on those private clones, non-Q0 `ControlStatusReference` values are suppressed; +4. identity-significant fields are unchanged, and P1 explicitly recomputes the identity/fingerprint and requires it to be exactly equal to the original model; +5. recovery therefore sees exactly one command focus: `AA1C1F08R4Q0/CSWI1.Pos`; +6. the existing A2.1 focus-chain logic adds the exact Q0 status plus corroborating CSWI/XCBR status candidates when the live IED exposes them; +7. dynamic NamedVariableList qualification runs in the private staging profile store; +8. existing G2.4 V2 proves one-URCB activation + an actual InformationReport against staging; +9. G2.4-C proves fresh-association RCB/DataSet cleanup closure; +10. optimistic concurrency still prevents overwriting newer live evidence; +11. only after every stage closes may the live profile be atomically replaced `InformationReportProven -> InformationReportProven`. -After recovery succeeds, ARSAS performs an independent read-only command-focus assessment again. Only if that assessment closes does it automatically continue into A3. The operator still waits for the exact A3 READY marker before issuing the one physical command. +Any failure before final replacement leaves the previous live profile authoritative. Recovery itself issues **zero control commands** and cannot mark `ProductionEligible`. ## Armed transaction -The existing `DynamicReportSpontaneousDataChangeCommissioningService` remains authoritative for the report transaction: +The existing `DynamicReportSpontaneousDataChangeCommissioningService` and `DynamicReportCommandBoundDataChangeCommissioningService` remain authoritative for the report/witness proof: - one exact InformationReport-proven URCB; - one bounded temporary dynamic DataSet; @@ -67,61 +85,79 @@ The existing `DynamicReportSpontaneousDataChangeCommissioningService` remains au - dupd disabled; - `OptFlds`: reason-for-inclusion + DataSet-name; - exact RptID/DataSet/member/reason validation; +- a separate read-only MMS witness association; +- final pre-command exact member baseline; +- exact command-bound high-speed transition sampling; +- exact DataSet-index correlation; - report monitor cleanup; - TrgOps/OptFlds restoration; - fresh-association cleanup closure. -A separate auxiliary MMS association is strictly read-only. It captures the final pre-command baseline and then samples only the qualified A2.1 command-focus members at high speed. +The core A3 service still does not execute a control command. It remains an observer of the established runtime diagnostic and the physical status/report evidence. -## Command authority +## One-shot auto stimulus -P1 does not call or wrap `ExecuteControlAsync`. +The new `DynamicReportQ0TargetLockedAutoA3CommissioningService` removes only the operator timing race. -The operator issues exactly one already-proven safe OPEN/CLOSE through the normal ARSAS control UI **only after** this status appears: +When the core A3 reports its final READY marker after the report path is armed and the read-only final baseline is captured, the coordinator immediately performs one final control inspection. If and only if the target is still exactly operationally ready and `Closed`, it constructs one normal `Iec61850ControlCommandRequest` and calls the already-existing: -`G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND` +`Iec61850MonitorRuntime.ExecuteControlAsync(...)` -The A3 witness consumes the already-existing `Iec61850MonitorRuntime.Diagnostic` entry beginning with: +No new MMS control implementation is introduced. The normal runtime remains responsible for the existing SBO/SBOw/Operate/CommandTermination sequence and wire evidence. Its existing diagnostic: `Control execution requested:` -That diagnostic is emitted by the existing runtime before native control execution. P1 therefore observes the established control path without inserting a new SBO/SBOw/Operate hook, delaying it, or re-issuing it. +is emitted before native control execution and is therefore consumed by the already-armed A3 witness exactly as before. + +The dispatch policy is deliberately one-shot: + +- maximum automatic dispatch count: 1; +- requested value: `Open`; +- retry: false; +- automatic CLOSE: false; +- automatic opposite command: false; +- automatic restore: false. + +If the READY-time state inspection fails, the A3 coordinator cancels the wait fail-closed rather than waiting for or synthesizing a command. If an already-dispatched physical command later returns ambiguous/error evidence, P1 does not retry it; runtime wire evidence plus physical transition/report evidence remain authoritative. ## PASS contract -A3 PASS requires all of the following in the same bounded armed window: +A3 PASS still requires all of the following in the same bounded armed window: 1. core dchg-only activation is proven; -2. the exact existing ARSAS command is captured after the final read-only baseline is ready; -3. at least one qualified command-focus MMS member changes after that command; +2. the exact runtime request for `AA1C1F08R4Q0/CSWI1.Pos -> Open` is captured after the final read-only baseline is ready; +3. at least one qualified Q0 command-focus MMS member changes after that command; 4. a valid spontaneous InformationReport is received with reason-for-inclusion `data-change`; 5. the report includes at least one **same exact DataSet index** as the post-command qualified transition; 6. report monitor cleanup succeeds; 7. temporary proof fields are restored; 8. fresh-association cleanup closure succeeds. -The evidence window records the command object/request, transition member/index/before/after values, report included indexes/reasons, correlated indexes, and cleanup state. +The evidence window remains authoritative for command object/request, transition member/index/before/after values, report included indexes/reasons, correlated indexes, and cleanup state. ## Failure localization -The combined proof separates several useful failure classes: +The combined proof now separates these useful failure classes: -- read-only assessment cannot resolve the old exact envelope -> model/profile identity problem; -- recovery DataSet qualification fails -> command-focus member / NamedVariableList capability problem; old profile remains untouched; +- exact identity/fingerprint mismatch -> auto control impossible, zero commands; +- Q0 object/status mismatch -> auto control impossible, zero commands; +- Q0 not `Closed` / not operationally ready -> auto control impossible, zero commands; +- target-scoped recovery cannot preserve model fingerprint -> recovery/control blocked; +- recovery DataSet qualification fails -> Q0 NamedVariableList capability problem; old profile remains untouched; - staged G2.4 fails -> RCB activation or actual InformationReport problem; old profile remains untouched; - staged G2.4-C fails -> fresh cleanup closure problem; old profile remains untouched; -- concurrency gate fails -> another qualification action changed the live evidence; recovery refuses to overwrite it; -- report path never arms -> activation/configuration problem; -- command is not captured -> ARSAS stimulus/capture problem; -- command captured but no qualified transition -> wrong/non-changing qualified member or physical/control feedback problem; -- command-bound qualified transition occurs but no dchg report -> report emission/receive-path problem; +- concurrency gate fails -> newer profile evidence exists; recovery refuses overwrite; +- report path never arms -> zero auto commands; +- READY-time reinspection fails -> zero auto commands and no retry; +- exact Q0 command captured but no qualified transition -> physical/control feedback problem; +- Q0 transition occurs but no dchg report -> report emission/receive-path problem; - dchg report arrives but includes different indexes -> report/member correlation problem; -- report succeeds but cleanup fails -> production remains ineligible and cleanup must be fixed first. +- report succeeds but cleanup fails -> production remains ineligible. ## Production boundary A3 success is intentionally weaker than production eligibility. -The recovery path may atomically replace one `InformationReportProven` profile with another `InformationReportProven` profile after stronger command-focus staging evidence, but neither recovery nor A3 can advance to `ProductionEligible`. A3 itself remains read-only with respect to persisted profile state. Smart Auto production authorization therefore stays fail-closed until later shadow verification and the complete G2.6 regression acceptance explicitly advance the profile. +The recovery path may atomically replace one `InformationReportProven` profile with another `InformationReportProven` profile after stronger Q0-focused staging evidence, but neither recovery nor Auto A3 can advance to `ProductionEligible`. The core A3 remains read-only with respect to persisted profile state. Smart Auto production authorization therefore stays fail-closed until later shadow verification and the complete G2.6 regression acceptance explicitly advance the profile. -The ARIEC engine pinned by P1 already contains the P0 production consumer (PR #97), but that consumer remains fail-closed for the current field IED while the profile is below `ProductionEligible`. \ No newline at end of file +The ARIEC engine pinned by P1 already contains the P0 production consumer (PR #97), but that consumer remains fail-closed for the current field IED while the profile is below `ProductionEligible`. From fa50de39715b7ba1650d784e640db0e6a2f8953e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 16:55:53 +0700 Subject: [PATCH 27/77] G2.6 P1: suppress clone status without firing shared observers --- ...amicReportQ0TargetLockedAutoA3CommissioningService.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs b/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs index 03de11c8..d1d5431c 100644 --- a/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs +++ b/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs @@ -298,10 +298,13 @@ private static SignalDefinition[] CreateTargetScopedRecoveryModel(IReadOnlyList< !SameUserReference(clone.ObjectReference, TargetControlReference) && !string.IsNullOrWhiteSpace(clone.ControlStatusReference)) { - if (statusSetter is not null) - statusSetter.Invoke(clone, [string.Empty]); + // Prefer direct private backing-field mutation on the private clone. A + // MemberwiseClone can carry event delegates; invoking a notifying setter + // could otherwise wake observers that belong to the live signal instance. + if (backingField is not null) + backingField.SetValue(clone, string.Empty); else - backingField!.SetValue(clone, string.Empty); + statusSetter!.Invoke(clone, [string.Empty]); } clones[index] = clone; From aac64afead06b09d0697df13aca484731606744e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 16:57:33 +0700 Subject: [PATCH 28/77] G2.6 P1: type one-shot control task with command result --- ...cReportQ0TargetLockedAutoA3CommissioningService.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs b/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs index d1d5431c..480114b0 100644 --- a/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs +++ b/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs @@ -191,7 +191,7 @@ public async Task RunAsync( return result; } - private static async Task DispatchOneShotOpenAsync( + private static async Task DispatchOneShotOpenAsync( Iec61850MonitorRuntime runtime, Iec61850MonitorDevice device, SignalDefinition target, @@ -210,13 +210,14 @@ private static async Task DispatchOneShotOpenAsync( catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) { failBeforeDispatch(ex); - return; + throw new InvalidOperationException("Q0 READY-time control inspection failed; no control command was sent.", ex); } if (target.ControlCommandBusy) { - failBeforeDispatch(new InvalidOperationException($"Exact A3 target {TargetControlReference} became busy at READY. No control command was sent.")); - return; + var ex = new InvalidOperationException($"Exact A3 target {TargetControlReference} became busy at READY. No control command was sent."); + failBeforeDispatch(ex); + throw ex; } var request = new Iec61850ControlCommandRequest @@ -238,7 +239,7 @@ private static async Task DispatchOneShotOpenAsync( // "Control execution requested:" diagnostic is emitted synchronously before the // native ARIEC control await, so the armed A3 witness captures the exact request. // No separate SBO/SBOw/Operate implementation exists here. - await runtime.ExecuteControlAsync(device.DeviceId, request, cancellationToken).ConfigureAwait(false); + return await runtime.ExecuteControlAsync(device.DeviceId, request, cancellationToken).ConfigureAwait(false); } private static async Task RequireClosedOperationalTargetAsync( From e3e3ed003cd5347c810bd75084e7eabee6d6356a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:00:13 +0700 Subject: [PATCH 29/77] G2.6 P1: align clone regression with fail-closed backing-field mutation --- tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index ffa9de01..a563d86d 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -141,7 +141,8 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde Assert.Contains("MemberwiseClone", auto, StringComparison.Ordinal); Assert.Contains("CreateTargetScopedRecoveryModel", auto, StringComparison.Ordinal); - Assert.Contains("statusSetter.Invoke(clone, [string.Empty])", auto, StringComparison.Ordinal); + Assert.Contains("backingField.SetValue(clone, string.Empty)", auto, StringComparison.Ordinal); + Assert.Contains("statusSetter!.Invoke(clone, [string.Empty])", auto, StringComparison.Ordinal); Assert.Contains("scopedCommands.Length != 1", auto, StringComparison.Ordinal); Assert.Contains("DynamicReportQualificationIdentity.Build(device, recoverySignals)", auto, StringComparison.Ordinal); Assert.DoesNotContain("ControlStatusReference", identity, StringComparison.Ordinal); From 4eedc1449b15ddc24f048040805cef4e508a6dd9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:00:34 +0700 Subject: [PATCH 30/77] G2.6 P1: align recovery regression with automatic Q0 coordinator --- ...mandFocusRequalificationRegressionTests.cs | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs b/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs index 31a22dc6..28a2667a 100644 --- a/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs @@ -64,23 +64,28 @@ public void Recovery_UsesExistingG23QualificationPrimitive_AndExistingG24Physica } [Fact] - public void A3Ui_OffersRecoveryOnlyAfterReadOnlyAssessment_ThenReassessesBeforeAutomaticArm() + public void Q0AutoCoordinator_AssessesThenRecoversThenReassessesBeforeA3Arm() { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); - var assess = ui.IndexOf("recovery.AssessAsync", StringComparison.Ordinal); - var offer = ui.IndexOf("Run transactional command-focus recovery now?", StringComparison.Ordinal); - var run = ui.IndexOf("recovery.RunAsync", StringComparison.Ordinal); - var post = ui.IndexOf("postRecovery = await recovery.AssessAsync", StringComparison.Ordinal); - var a3 = ui.IndexOf("new DynamicReportCommandBoundDataChangeCommissioningService", StringComparison.Ordinal); + var assess = auto.IndexOf("var assessment = await recovery.AssessAsync", StringComparison.Ordinal); + var requiresRecovery = auto.IndexOf("if (assessment.RequiresRequalification)", StringComparison.Ordinal); + var run = auto.IndexOf("var recoveryResult = await recovery.RunAsync", StringComparison.Ordinal); + var post = auto.IndexOf("var postRecovery = await recovery.AssessAsync", StringComparison.Ordinal); + var preArm = auto.IndexOf("post-recovery pre-arm", StringComparison.Ordinal); + var a3 = auto.IndexOf("new DynamicReportCommandBoundDataChangeCommissioningService", StringComparison.Ordinal); Assert.True(assess >= 0); - Assert.True(offer > assess); - Assert.True(run > offer); + Assert.True(requiresRecovery > assess); + Assert.True(run > requiresRecovery); Assert.True(post > run); - Assert.True(a3 > post); - Assert.Contains("DO NOT command until the exact A3 READY marker appears", ui, StringComparison.Ordinal); - Assert.Contains("keep the current InformationReportProven live profile untouched on ANY failure", ui, StringComparison.Ordinal); + Assert.True(preArm > post); + Assert.True(a3 > preArm); + Assert.Contains("ZERO control commands are permitted during recovery", auto, StringComparison.Ordinal); + Assert.Contains("The previous live profile remains authoritative and no control command was sent", auto, StringComparison.Ordinal); + Assert.Contains("DynamicReportQ0TargetLockedAutoA3CommissioningService", ui, StringComparison.Ordinal); + Assert.DoesNotContain("Run transactional command-focus recovery now?", ui, StringComparison.Ordinal); } private static string Read(string relativePath) @@ -98,4 +103,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} \ No newline at end of file +} From 0db2fef5022d7f87e62782aa37914e7839976223 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:46:23 +0700 Subject: [PATCH 31/77] G2.6 P1: preserve dchg report receive time for command ordering proof --- ...cReportSpontaneousDataChangeCommissioningService.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs b/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs index e97c1f18..8e519195 100644 --- a/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs +++ b/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs @@ -22,6 +22,7 @@ internal sealed class DynamicReportSpontaneousDataChangeCommissioningResult public bool ProofFieldRestoreSucceeded { get; init; } public bool FreshCleanupClosureSucceeded { get; init; } public bool AssociationHealthyAfterReport { get; init; } + public DateTimeOffset? ReportReceivedAtUtc { get; init; } public string Summary { get; init; } = string.Empty; public ArMms.MmsDynamicReportIedIdentity? Identity { get; init; } public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; } @@ -121,6 +122,7 @@ profile.AcceptedEnvelope is null || var includedIndexes = Array.Empty(); var includedMembers = Array.Empty(); var includedReasons = Array.Empty(); + DateTimeOffset? reportReceivedAtUtc = null; var reportId = string.Empty; var failureSummary = string.Empty; @@ -302,16 +304,17 @@ afterEnable is not null && afterEnable.IsSuccess && foreach (var frame in receive.Reports) { var validation = ValidateSpontaneousDataChangeFrame(frame, reportId, plan.DataSetReference, qualifiedReferences); - evidence.Add($"G2.5-A report candidate: rptId={TextOrDash(frame.Header.ReportId)}; dataset={TextOrDash(frame.Header.DataSetReference)}; decoder={frame.DecoderMode}; values={frame.Values.Count}; included=[{string.Join(",", frame.IncludedDataSetIndexes)}]; valid={validation.IsSuccess}; reasons=[{string.Join(",", validation.Reasons)}]; reason={validation.Reason}"); + evidence.Add($"G2.5-A report candidate: receivedAt={frame.ReceivedAt:O}; rptId={TextOrDash(frame.Header.ReportId)}; dataset={TextOrDash(frame.Header.DataSetReference)}; decoder={frame.DecoderMode}; values={frame.Values.Count}; included=[{string.Join(",", frame.IncludedDataSetIndexes)}]; valid={validation.IsSuccess}; reasons=[{string.Join(",", validation.Reasons)}]; reason={validation.Reason}"); if (!validation.IsSuccess) continue; spontaneousProven = true; associationHealthyAfterReport = auxiliary.IsMmsInitiated; + reportReceivedAtUtc = frame.ReceivedAt; includedIndexes = validation.IncludedIndexes.ToArray(); includedMembers = validation.IncludedMemberReferences.ToArray(); includedReasons = validation.Reasons.ToArray(); - evidence.Add($"G2.5-A spontaneous dchg proof: success={spontaneousProven && associationHealthyAfterReport}; kind=DataChange; actual=true; identity=true; mappedIncludedMembers={includedIndexes.Length}; associationHealthy={associationHealthyAfterReport}; GIrequested=false"); + evidence.Add($"G2.5-A spontaneous dchg proof: success={spontaneousProven && associationHealthyAfterReport}; receivedAt={reportReceivedAtUtc:O}; kind=DataChange; actual=true; identity=true; mappedIncludedMembers={includedIndexes.Length}; associationHealthy={associationHealthyAfterReport}; GIrequested=false"); break; } @@ -394,6 +397,7 @@ afterEnable is not null && afterEnable.IsSuccess && ProofFieldRestoreSucceeded = fieldRestore, FreshCleanupClosureSucceeded = freshClosure, AssociationHealthyAfterReport = associationHealthyAfterReport, + ReportReceivedAtUtc = reportReceivedAtUtc, Summary = success ? $"G2.5-A PASS: exact G2.4-proven URCB delivered a spontaneous data-change InformationReport without GI for {includedIndexes.Length} included member(s), and monitor/proof-field/fresh-association cleanup all passed. Profile remains InformationReportProven; production dynamic reporting remains OFF." : "G2.5-A did not prove the complete spontaneous dchg gate. Cleanup evidence is retained; the InformationReportProven profile is unchanged and production dynamic reporting remains OFF.", @@ -537,4 +541,4 @@ private static DynamicReportSpontaneousDataChangeCommissioningResult FailedBefor => new() { Summary = summary + " No RCB/DataSet mutation was attempted.", Identity = identity, InputProfile = profile, RcbReference = rcbReference, MemberReferences = memberReferences.ToArray(), MonitorCleanupSucceeded = true, ProofFieldRestoreSucceeded = true, FreshCleanupClosureSucceeded = true, ProfilePath = profilePath, EvidenceLines = evidence.ToArray() }; private static string TextOrDash(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); -} +} \ No newline at end of file From dd054b6975f2c28c7e54e7917c048e9e267c64b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:47:51 +0700 Subject: [PATCH 32/77] G2.6 P1: bind A3 PASS to accepted control and post-command report --- ...mandBoundDataChangeCommissioningService.cs | 110 +++++++++++++++--- 1 file changed, 91 insertions(+), 19 deletions(-) diff --git a/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs b/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs index b20c9a08..10cea36b 100644 --- a/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs +++ b/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs @@ -36,6 +36,9 @@ internal sealed class DynamicReportCommandBoundA3CommissioningResult public bool IsSuccess { get; init; } public bool IsBlocked { get; init; } public bool CommandBoundReportCorrelationProven { get; init; } + public bool NativeControlAcceptanceProven { get; init; } + public bool ReportAfterCommandProven { get; init; } + public DateTimeOffset? NativeControlAcceptedAtUtc { get; init; } public IReadOnlyList CorrelatedIndexes { get; init; } = Array.Empty(); public IReadOnlyList CorrelatedMemberReferences { get; init; } = Array.Empty(); public DynamicReportSpontaneousDataChangeCommissioningResult CoreResult { get; init; } = new(); @@ -57,16 +60,17 @@ internal sealed record DynamicReportCommandBoundA3EligibleTarget( /// A second isolated MMS association is read-only and is used only to prove that the exact /// pre-existing ARSAS control command caused a transition on a member that belongs to the /// exact G2.4-proven DataSet envelope. The command itself remains owned by the existing -/// Iec61850MonitorRuntime control path; this service only observes its already-existing -/// "Control execution requested:" Diagnostic entry and never calls ExecuteControlAsync. +/// Iec61850MonitorRuntime control path; this service observes the runtime request plus the +/// later successful native-control diagnostic and never calls ExecuteControlAsync. /// /// PASS therefore requires all of the following in one bounded armed window: /// - exact InformationReportProven identity/profile and G2.4 RCB/member sequence; /// - at least one ARSAS control object whose A2.1 focus chain intersects that exact sequence; /// - core dchg-only activation/report/cleanup success with GI disabled; /// - one exact runtime-observed ARSAS command after the witness baseline is ready; +/// - later successful native control-result/wire evidence for that exact request; /// - a post-command MMS transition on a qualified command-focus member; -/// - the dchg InformationReport includes the same DataSet index. +/// - the dchg InformationReport was received strictly after the captured command and includes the same DataSet index. /// /// This service never saves or advances the qualification profile and cannot set /// ProductionEligible. Production automatic dynamic reporting remains a later gate. @@ -76,6 +80,7 @@ internal sealed class DynamicReportCommandBoundDataChangeCommissioningService internal const string ReadyMarker = "G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND"; internal const string CommandCapturedMarker = "G2.6-P1 A3 COMMAND CAPTURED"; internal const string TransitionMarker = "G2.6-P1 A3 COMMAND-BOUND TRANSITION"; + internal const string NativeAcceptedMarker = "G2.6-P1 A3 NATIVE CONTROL ACCEPTED"; internal static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10); internal static readonly TimeSpan CommandWaitWindow = TimeSpan.FromSeconds(45); internal static readonly TimeSpan CommandTransitionWindow = TimeSpan.FromSeconds(5); @@ -103,9 +108,9 @@ public async Task RunAsync( var evidence = new List { - "G2.6-P1 A3 contract: exact existing ARSAS command -> read-only command-bound qualified-member transition -> dchg InformationReport on the same DataSet index -> mandatory G2.5-A cleanup.", - "G2.6-P1 A3 control safety: this service never calls ExecuteControlAsync and never writes SBO/SBOw/Operate/Cancel; command authority remains the existing Iec61850MonitorRuntime path.", - "G2.6-P1 A3 report safety: core path is strict dchg-only with GI=false, integrity=false, qchg=false and dupd=false. The read-only witness performs no RCB/DataSet operation.", + "G2.6-P1 A3 contract: exact existing ARSAS command -> accepted native MMS control result -> read-only command-bound qualified-member transition -> post-command dchg InformationReport on the same DataSet index -> mandatory G2.5-A cleanup.", + "G2.6-P1 A3 control safety: this service never calls ExecuteControlAsync and never writes SBO/SBOw/Operate/Cancel; command authority remains the existing Iec61850MonitorRuntime path. Request diagnostics alone cannot prove PASS.", + "G2.6-P1 A3 report safety: core path is strict dchg-only with GI=false, integrity=false, qchg=false and dupd=false. The selected valid report receive timestamp must be strictly after the captured command time.", "G2.6-P1 A3 profile safety: persisted InformationReportProven evidence is read-only; this service cannot save, advance or mark ProductionEligible." }; @@ -196,22 +201,31 @@ await witnessSession.ConnectAsync( var armed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var commandCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var nativeCommandAcceptance = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var witnessReady = 0; void RuntimeDiagnosticHandler(DiagnosticEntry entry) { - if (Volatile.Read(ref witnessReady) != 1) - return; - if (!DynamicReportCommandBoundStimulusWitnessServiceV3.TryBuildRuntimeIntent( + if (Volatile.Read(ref witnessReady) == 1 && + DynamicReportCommandBoundStimulusWitnessServiceV3.TryBuildRuntimeIntent( entry, device, fullModelSignals, - out var intent) || intent is null) + out var intent) && intent is not null && + eligibleTargets.Any(target => ReferenceEquals(target.Signal, intent.Signal) || + SameReference(target.Signal.ObjectReference, intent.Signal.ObjectReference))) + { + commandCapture.TrySetResult(intent); + } + + if (!commandCapture.Task.IsCompletedSuccessfully) return; - if (!eligibleTargets.Any(target => ReferenceEquals(target.Signal, intent.Signal) || - SameReference(target.Signal.ObjectReference, intent.Signal.ObjectReference))) + + var captured = commandCapture.Task.Result; + if (!IsAcceptedNativeControlResultDiagnostic(entry, captured)) return; - commandCapture.TrySetResult(intent); + + nativeCommandAcceptance.TrySetResult(ToUtc(entry.Time)); } runtime.Diagnostic += RuntimeDiagnosticHandler; @@ -274,6 +288,18 @@ void RuntimeDiagnosticHandler(DiagnosticEntry entry) evidence.AddRange(coreResult.EvidenceLines.Select(line => "CORE/" + line)); evidence.AddRange(witnessResult.EvidenceLines.Select(line => "WITNESS/" + line)); + var nativeControlAccepted = nativeCommandAcceptance.Task.IsCompletedSuccessfully; + var nativeAcceptedAtUtc = nativeControlAccepted ? nativeCommandAcceptance.Task.Result : (DateTimeOffset?)null; + if (nativeControlAccepted) + evidence.Add($"{NativeAcceptedMarker}: object={witnessResult.CommandSignalReference}; requested={witnessResult.RequestedValue}; acceptedAt={nativeAcceptedAtUtc:O}; source=Iec61850MonitorRuntime successful native-control diagnostic."); + else if (witnessResult.CommandCaptured) + evidence.Add("G2.6-P1 A3 native control acceptance: NOT PROVEN. A request diagnostic alone is insufficient; rejected/NotSent/ambiguous control cannot satisfy PASS."); + + var reportAfterCommand = witnessResult.CommandObservedAtUtc.HasValue && + coreResult.ReportReceivedAtUtc.HasValue && + coreResult.ReportReceivedAtUtc.Value > witnessResult.CommandObservedAtUtc.Value; + evidence.Add($"G2.6-P1 A3 report ordering: commandAt={witnessResult.CommandObservedAtUtc?.ToString("O") ?? "-"}; reportReceivedAt={coreResult.ReportReceivedAtUtc?.ToString("O") ?? "-"}; strictlyAfterCommand={reportAfterCommand}."); + var changedIndexes = witnessResult.Transitions .Select(transition => transition.Index) .Distinct() @@ -287,14 +313,16 @@ void RuntimeDiagnosticHandler(DiagnosticEntry entry) var correlation = coreResult.SpontaneousDataChangeProven && witnessResult.CommandCaptured && + nativeControlAccepted && witnessResult.CommandBoundTransitionProven && + reportAfterCommand && correlatedIndexes.Length > 0; var success = coreResult.IsSuccess && correlation; string diagnosis; if (success) { - diagnosis = $"G2.6-P1 A3 PASS: exact ARSAS command {witnessResult.CommandSignalReference} produced a command-bound transition and the dchg InformationReport included the same exact DataSet index(es) [{string.Join(",", correlatedIndexes)}]; monitor/proof-field/fresh-association cleanup all passed."; + diagnosis = $"G2.6-P1 A3 PASS: exact ARSAS command {witnessResult.CommandSignalReference} had successful native control evidence, produced a command-bound transition, and a later dchg InformationReport included the same exact DataSet index(es) [{string.Join(",", correlatedIndexes)}]; monitor/proof-field/fresh-association cleanup all passed."; } else if (!coreResult.ActivationProven) { @@ -304,24 +332,32 @@ void RuntimeDiagnosticHandler(DiagnosticEntry entry) { diagnosis = "A3 report path armed, but no eligible existing ARSAS command was captured after the read-only baseline became ready."; } + else if (!nativeControlAccepted) + { + diagnosis = "A3 captured a control request, but successful native MMS control-result evidence for that exact request was not observed. Request intent alone cannot prove command acceptance."; + } else if (!witnessResult.CommandBoundTransitionProven) { - diagnosis = "A3 captured the exact ARSAS command, but no qualified command-focus member changed in the bounded high-speed witness window."; + diagnosis = "A3 captured and natively accepted the exact ARSAS command, but no qualified command-focus member changed in the bounded high-speed witness window."; } else if (!coreResult.SpontaneousDataChangeProven) { - diagnosis = $"A3 captured the command and witnessed qualified DataSet index(es) [{string.Join(",", changedIndexes)}] change, but no valid dchg InformationReport arrived. This isolates the remaining fault to dchg/report emission or receive-path evidence."; + diagnosis = $"A3 captured an accepted command and witnessed qualified DataSet index(es) [{string.Join(",", changedIndexes)}] change, but no valid dchg InformationReport arrived. This isolates the remaining fault to dchg/report emission or receive-path evidence."; + } + else if (!reportAfterCommand) + { + diagnosis = $"A3 received a valid dchg report at {coreResult.ReportReceivedAtUtc?.ToString("O") ?? ""}, but it was not received strictly after the captured command at {witnessResult.CommandObservedAtUtc?.ToString("O") ?? ""}. Pre-command report traffic cannot satisfy command-bound A3."; } else if (correlatedIndexes.Length == 0) { - diagnosis = $"A3 received a valid dchg report, but its included indexes [{string.Join(",", coreResult.IncludedIndexes)}] did not match command-bound changed indexes [{string.Join(",", changedIndexes)}]."; + diagnosis = $"A3 received a valid post-command dchg report, but its included indexes [{string.Join(",", coreResult.IncludedIndexes)}] did not match command-bound changed indexes [{string.Join(",", changedIndexes)}]."; } else { diagnosis = "A3 command/report correlation did not close every required gate."; } - evidence.Add($"G2.6-P1 A3 combined: coreSuccess={coreResult.IsSuccess}; activation={coreResult.ActivationProven}; dchg={coreResult.SpontaneousDataChangeProven}; cleanup={coreResult.MonitorCleanupSucceeded}/{coreResult.ProofFieldRestoreSucceeded}/{coreResult.FreshCleanupClosureSucceeded}; command={witnessResult.CommandCaptured}; commandTransition={witnessResult.CommandBoundTransitionProven}; changed=[{string.Join(",", changedIndexes)}]; reportIncluded=[{string.Join(",", coreResult.IncludedIndexes)}]; correlated=[{string.Join(",", correlatedIndexes)}]; success={success}"); + evidence.Add($"G2.6-P1 A3 combined: coreSuccess={coreResult.IsSuccess}; activation={coreResult.ActivationProven}; dchg={coreResult.SpontaneousDataChangeProven}; cleanup={coreResult.MonitorCleanupSucceeded}/{coreResult.ProofFieldRestoreSucceeded}/{coreResult.FreshCleanupClosureSucceeded}; command={witnessResult.CommandCaptured}; nativeAccepted={nativeControlAccepted}; commandTransition={witnessResult.CommandBoundTransitionProven}; reportAfterCommand={reportAfterCommand}; changed=[{string.Join(",", changedIndexes)}]; reportIncluded=[{string.Join(",", coreResult.IncludedIndexes)}]; correlated=[{string.Join(",", correlatedIndexes)}]; success={success}"); evidence.Add("G2.6-P1 A3 diagnosis: " + diagnosis); evidence.Add("G2.6-P1 A3 state: profile remains InformationReportProven. Production automatic dynamic reporting remains OFF; shadow/regression acceptance is still required before ProductionEligible."); @@ -329,6 +365,9 @@ void RuntimeDiagnosticHandler(DiagnosticEntry entry) { IsSuccess = success, CommandBoundReportCorrelationProven = correlation, + NativeControlAcceptanceProven = nativeControlAccepted, + ReportAfterCommandProven = reportAfterCommand, + NativeControlAcceptedAtUtc = nativeAcceptedAtUtc, CorrelatedIndexes = correlatedIndexes, CorrelatedMemberReferences = correlatedMembers, CoreResult = coreResult, @@ -397,6 +436,39 @@ internal static int[] CorrelateIndexes( .ToArray(); } + private static bool IsAcceptedNativeControlResultDiagnostic( + DiagnosticEntry entry, + DynamicReportObservedCommandIntent command) + { + if (!entry.Level.Equals("INFO", StringComparison.OrdinalIgnoreCase)) + return false; + + var message = entry.Message ?? string.Empty; + if (!message.StartsWith("Control ", StringComparison.OrdinalIgnoreCase)) + return false; + if (!message.Contains($": {command.Signal.ObjectReference};", StringComparison.OrdinalIgnoreCase)) + return false; + if (!message.Contains($"requested={command.RequestedValue};", StringComparison.OrdinalIgnoreCase)) + return false; + if (!message.Contains("wire=", StringComparison.OrdinalIgnoreCase)) + return false; + if (message.Contains("NOT SENT TO IED", StringComparison.OrdinalIgnoreCase) || + message.Contains("no response captured", StringComparison.OrdinalIgnoreCase) || + message.Contains("no wire evidence returned", StringComparison.OrdinalIgnoreCase)) + return false; + + return true; + } + + private static DateTimeOffset ToUtc(DateTime value) + { + if (value.Kind == DateTimeKind.Utc) + return new DateTimeOffset(value); + if (value.Kind == DateTimeKind.Local) + return new DateTimeOffset(value).ToUniversalTime(); + return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local)).ToUniversalTime(); + } + private static async Task RunCommandWitnessAsync( ArMms.MmsClientSession session, IReadOnlyList exactQualifiedPoints, @@ -630,4 +702,4 @@ private sealed class ReadBatch public IReadOnlyList Values { get; init; } = Array.Empty(); public string Message { get; init; } = string.Empty; } -} +} \ No newline at end of file From d9a8f83b06b025b954c486ad467581df2347a387 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:48:21 +0700 Subject: [PATCH 33/77] test: lock P1 native acceptance and report ordering gates --- .../G26P1DeterministicA3RegressionTests.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index a563d86d..a4ca7532 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -43,6 +43,24 @@ public void A3_PassRequiresSameDataSetIndexForCommandTransitionAndDchgReport() Assert.Contains("var success = coreResult.IsSuccess && correlation", source, StringComparison.Ordinal); } + [Fact] + public void A3_PassRequiresSuccessfulNativeControlEvidence_AndReportStrictlyAfterCommand() + { + var wrapper = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + var core = Read("Services/DynamicReportSpontaneousDataChangeCommissioningService.cs"); + + Assert.Contains("IsAcceptedNativeControlResultDiagnostic", wrapper, StringComparison.Ordinal); + Assert.Contains("nativeCommandAcceptance", wrapper, StringComparison.Ordinal); + Assert.Contains("nativeControlAccepted &&", wrapper, StringComparison.Ordinal); + Assert.Contains("Request intent alone cannot prove command acceptance", wrapper, StringComparison.Ordinal); + Assert.Contains("coreResult.ReportReceivedAtUtc.Value > witnessResult.CommandObservedAtUtc.Value", wrapper, StringComparison.Ordinal); + Assert.Contains("reportAfterCommand &&", wrapper, StringComparison.Ordinal); + Assert.Contains("Pre-command report traffic cannot satisfy command-bound A3", wrapper, StringComparison.Ordinal); + Assert.Contains("public DateTimeOffset? ReportReceivedAtUtc", core, StringComparison.Ordinal); + Assert.Contains("receivedAt={frame.ReceivedAt:O}", core, StringComparison.Ordinal); + Assert.Contains("reportReceivedAtUtc = frame.ReceivedAt", core, StringComparison.Ordinal); + } + [Fact] public void A3_ReusesStrictDchgOnlyCoreAndMandatoryCleanup() { @@ -187,4 +205,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} +} \ No newline at end of file From ab27ab212c70457f4fa8463eb60476e6938c1409 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:56:07 +0700 Subject: [PATCH 34/77] docs: record physical A3 acceptance and final correlation hardening --- docs/G2_6_P1_DETERMINISTIC_A3.md | 166 +++++++++++++------------------ 1 file changed, 68 insertions(+), 98 deletions(-) diff --git a/docs/G2_6_P1_DETERMINISTIC_A3.md b/docs/G2_6_P1_DETERMINISTIC_A3.md index 0ea65022..385aaf9f 100644 --- a/docs/G2_6_P1_DETERMINISTIC_A3.md +++ b/docs/G2_6_P1_DETERMINISTIC_A3.md @@ -2,24 +2,19 @@ ## Goal -Close the field A3 proof with one exact, already-proven ARSAS control path and remove both sources of physical-test ambiguity discovered during P1: +Close the field A3 proof with one exact, already-proven ARSAS control path and remove the sources of physical-test ambiguity discovered during P1. -1. generic command-focus recovery selected the first eight alphabetically ordered control-status members and excluded the intended Q0 control; -2. manual READY → operator click timing could expire without any command being captured. +The field-bounded P1 chain is: -The field-bounded P1 chain is now: - -`AA1C1F08R4Q0/CSWI1.Pos one-shot OPEN -> qualified Q0 MMS status transition -> Dynamic URCB InformationReport(reason=data-change) on the same DataSet index -> cleanup` +`AA1C1F08R4Q0/CSWI1.Pos one-shot OPEN -> accepted native MMS control -> qualified Q0 MMS status transition -> post-command Dynamic URCB InformationReport(reason=data-change) on the same exact DataSet index -> cleanup` This is a commissioning proof only. It does **not** mark the IED `ProductionEligible` and it does **not** enable production automatic dynamic reporting. ## Entry point -Select the qualified field IED in ARSAS, then press: - -`Ctrl + Shift + A` +Select the qualified field IED in ARSAS, then press `Ctrl + Shift + A`. -For this P1 field build, the hotkey itself is the explicit commissioning action. There is no successful-path arm dialog, recovery dialog, or manual command dialog. The older A2.1 read-only/manual command witness remains separately available on `Ctrl + Shift + F`. +For this P1 field build, the hotkey itself is the explicit commissioning action. The older A2.1 read-only/manual command witness remains separately available on `Ctrl + Shift + F`. ## Exact field lock @@ -34,58 +29,25 @@ Auto A3 is deliberately bounded to all of the following exact values: - synchrocheck: disabled; - test mode: disabled. -The coordinator never converts the current state into a toggle. `Open`, intermediate, unknown, wrong identity, wrong status mapping, busy control, or a non-operational control model all block command dispatch. There is no automatic CLOSE, retry, opposite command, or restore command. - -## Preflight gates - -Before the report path is allowed to mutate an RCB, P1 requires: - -1. the connected model resolves to the exact field identity and model fingerprint above; -2. the exact Q0 control object exists and exposes the exact `ControlStatusReference` above; -3. the existing ARSAS control inspector reports the exact target operationally ready; -4. the exact target state is `Closed` before recovery/report arming; -5. the persisted profile is identity-compatible and exactly `InformationReportProven`; -6. the G2.4 RCB activation proof and InformationReport proof are successful; -7. the exact G2.4 member sequence still resolves on the live IED; -8. the Q0 A2.1 status/focus chain intersects the exact G2.4-proven DataSet member sequence; -9. no control command is already busy. +The coordinator never converts current state into a toggle. `Open`, intermediate, unknown, wrong identity, wrong status mapping, busy control, or a non-operational control model all block dispatch. There is no automatic CLOSE, retry, opposite command, or restore command. -The control state is checked again after any recovery and once more after the A3 final witness baseline is ready. The one-shot OPEN is dispatched only if that final READY-time inspection still says exactly `Closed`. +## Preflight and target-scoped recovery -## Field-discovered Q0 command-focus recovery +Before the report path is allowed to mutate an RCB, P1 requires exact identity/fingerprint, exact Q0 control/status mapping, operational readiness, exact `Closed` state, identity-compatible `InformationReportProven`, successful persisted G2.4 activation/report proof, live resolution of the persisted member sequence, Q0 command-focus intersection, and no control command already busy. -Physical P1 testing proved that the IED could already be `InformationReportProven` while the exact proven member envelope contained command statuses for DSQZ/ESQZ objects but not the intended `AA1C1F08R4Q0/CSWI1.Pos.stVal`. The previous generic recovery sorted all ARSAS commands by object reference and the eight-member cap was exhausted before Q0 was reached. +If Q0 is missing from the persisted G2.4 member envelope, P1 reuses transactional recovery on a **private target-scoped clone** of the discovered signal model. Live `SignalDefinition` instances are not modified. Non-Q0 command focus is suppressed only on private clones, identity-significant fields remain unchanged and are revalidated, qualification runs in a staging profile store, G2.4 V2 proves activation + actual InformationReport, G2.4-C proves fresh cleanup, optimistic concurrency prevents overwriting newer evidence, and the live profile is replaced only after all staging gates pass. Recovery itself issues zero control commands and cannot mark `ProductionEligible`. -P1 now reuses the transactional recovery with a **private target-scoped clone of the discovered signal model**: +## Armed transaction and one-shot control -1. the normal live `SignalDefinition` instances are never modified; -2. every signal is privately shallow-cloned; -3. only on those private clones, non-Q0 `ControlStatusReference` values are suppressed; -4. identity-significant fields are unchanged, and P1 explicitly recomputes the identity/fingerprint and requires it to be exactly equal to the original model; -5. recovery therefore sees exactly one command focus: `AA1C1F08R4Q0/CSWI1.Pos`; -6. the existing A2.1 focus-chain logic adds the exact Q0 status plus corroborating CSWI/XCBR status candidates when the live IED exposes them; -7. dynamic NamedVariableList qualification runs in the private staging profile store; -8. existing G2.4 V2 proves one-URCB activation + an actual InformationReport against staging; -9. G2.4-C proves fresh-association RCB/DataSet cleanup closure; -10. optimistic concurrency still prevents overwriting newer live evidence; -11. only after every stage closes may the live profile be atomically replaced `InformationReportProven -> InformationReportProven`. - -Any failure before final replacement leaves the previous live profile authoritative. Recovery itself issues **zero control commands** and cannot mark `ProductionEligible`. - -## Armed transaction - -The existing `DynamicReportSpontaneousDataChangeCommissioningService` and `DynamicReportCommandBoundDataChangeCommissioningService` remain authoritative for the report/witness proof: +The existing `DynamicReportSpontaneousDataChangeCommissioningService` and `DynamicReportCommandBoundDataChangeCommissioningService` remain authoritative for the dchg-only report/witness proof: - one exact InformationReport-proven URCB; - one bounded temporary dynamic DataSet; - `TrgOps`: dchg only; -- GI disabled; -- integrity disabled; -- qchg disabled; -- dupd disabled; +- GI/integrity/qchg/dupd disabled; - `OptFlds`: reason-for-inclusion + DataSet-name; - exact RptID/DataSet/member/reason validation; -- a separate read-only MMS witness association; +- separate read-only MMS witness association; - final pre-command exact member baseline; - exact command-bound high-speed transition sampling; - exact DataSet-index correlation; @@ -93,71 +55,79 @@ The existing `DynamicReportSpontaneousDataChangeCommissioningService` and `Dynam - TrgOps/OptFlds restoration; - fresh-association cleanup closure. -The core A3 service still does not execute a control command. It remains an observer of the established runtime diagnostic and the physical status/report evidence. +The core A3 service still does not execute a control command. The Q0 coordinator removes only the operator timing race. At READY it performs one final control inspection and, only if Q0 is still exactly operationally ready and `Closed`, calls the already-existing `Iec61850MonitorRuntime.ExecuteControlAsync(...)` once with `Open`. No new MMS control implementation is introduced. There is no retry, CLOSE, toggle/opposite command, or automatic restore. + +## Physical field acceptance — PASS, 2026-08-24 + +The physical acceptance run was executed on implementation head `4eedc1449b15ddc24f048040805cef4e508a6dd9` and produced the following operator-captured evidence: -## One-shot auto stimulus +- exact command: `AA1C1F08R4Q0/CSWI1.Pos -> Open`; +- command intent observed at `2026-08-24T10:21:37.0572634+00:00`; +- `AA1C1F08R4Q0/XCBR1.Pos.stVal` transitioned `bits(80) -> bits(40)` about `470.638 ms` after command; +- `AA1C1F08R4Q0/CSWI1.Pos.stVal` transitioned `bits(80) -> bits(40)` about `491.29 ms` after command; +- spontaneous InformationReport was proven with `reason=data-change`; +- report included exact DataSet indexes `[0,1]`; +- command-bound changed indexes were `[0,1]`; +- correlated indexes were `[0,1]`; +- report monitor cleanup passed; +- TrgOps/OptFlds restoration passed; +- fresh-association cleanup closure passed; +- report association remained healthy. -The new `DynamicReportQ0TargetLockedAutoA3CommissioningService` removes only the operator timing race. +That run closed the original P1 physical command -> qualified transition -> dchg InformationReport -> same exact DataSet index -> cleanup contract. -When the core A3 reports its final READY marker after the report path is armed and the read-only final baseline is captured, the coordinator immediately performs one final control inspection. If and only if the target is still exactly operationally ready and `Closed`, it constructs one normal `Iec61850ControlCommandRequest` and calls the already-existing: +## Final fail-closed correlation hardening before merge -`Iec61850MonitorRuntime.ExecuteControlAsync(...)` +Before merge, two additional false-positive paths were closed on merge-candidate head `d9a8f83b06b025b954c486ad467581df2347a387`. -No new MMS control implementation is introduced. The normal runtime remains responsible for the existing SBO/SBOw/Operate/CommandTermination sequence and wire evidence. Its existing diagnostic: +### Native control acceptance is mandatory -`Control execution requested:` +`Control execution requested:` is command intent only. It cannot independently satisfy PASS because it is emitted before native control execution completes. -is emitted before native control execution and is therefore consumed by the already-armed A3 witness exactly as before. +P1 now also requires a later successful native-control diagnostic from the **same existing runtime control path**, for the same exact object and requested value, with MMS response/wire evidence. `NotSent`, rejected, no-response, and otherwise unproven native control fail closed. No second SBO/SBOw/Operate implementation was introduced. -The dispatch policy is deliberately one-shot: +### Report reception must follow the command -- maximum automatic dispatch count: 1; -- requested value: `Open`; -- retry: false; -- automatic CLOSE: false; -- automatic opposite command: false; -- automatic restore: false. +The selected valid dchg frame preserves `MmsReportFrame.ReceivedAt` as `ReportReceivedAtUtc`. P1 now requires: -If the READY-time state inspection fails, the A3 coordinator cancels the wait fail-closed rather than waiting for or synthesizing a command. If an already-dispatched physical command later returns ambiguous/error evidence, P1 does not retry it; runtime wire evidence plus physical transition/report evidence remain authoritative. +`ReportReceivedAtUtc > CommandObservedAtUtc` -## PASS contract +before command/report correlation may pass. A valid unrelated dchg frame received before the command can no longer be combined with a later same-index MMS transition to create a false PASS. -A3 PASS still requires all of the following in the same bounded armed window: +These changes only make acceptance stricter; they do not broaden command authority or production eligibility. The original physical run predates these extra software gates, so it is recorded as physical acceptance of the original P1 contract, not falsely described as a physical rerun of the final hardening head. -1. core dchg-only activation is proven; -2. the exact runtime request for `AA1C1F08R4Q0/CSWI1.Pos -> Open` is captured after the final read-only baseline is ready; -3. at least one qualified Q0 command-focus MMS member changes after that command; -4. a valid spontaneous InformationReport is received with reason-for-inclusion `data-change`; -5. the report includes at least one **same exact DataSet index** as the post-command qualified transition; -6. report monitor cleanup succeeds; -7. temporary proof fields are restored; -8. fresh-association cleanup closure succeeds. +## Final PASS contract -The evidence window remains authoritative for command object/request, transition member/index/before/after values, report included indexes/reasons, correlated indexes, and cleanup state. +A final-head A3 PASS requires all of the following in the same bounded armed window: -## Failure localization +1. exact identity-compatible `InformationReportProven` profile; +2. exact Q0 command-focus intersection with the persisted G2.4 member sequence; +3. exact dchg-only URCB activation with no GI; +4. exact ARSAS Q0 command intent after the final read-only baseline; +5. successful native MMS control result/wire evidence for that exact request; +6. post-command transition on a qualified command-focus member; +7. valid spontaneous `reason=data-change` InformationReport; +8. selected report receive time strictly after the captured command time; +9. at least one same exact DataSet index between command-bound transition and report; +10. report monitor cleanup PASS; +11. TrgOps/OptFlds restore PASS; +12. fresh-association cleanup closure PASS. -The combined proof now separates these useful failure classes: +## Final CI validation -- exact identity/fingerprint mismatch -> auto control impossible, zero commands; -- Q0 object/status mismatch -> auto control impossible, zero commands; -- Q0 not `Closed` / not operationally ready -> auto control impossible, zero commands; -- target-scoped recovery cannot preserve model fingerprint -> recovery/control blocked; -- recovery DataSet qualification fails -> Q0 NamedVariableList capability problem; old profile remains untouched; -- staged G2.4 fails -> RCB activation or actual InformationReport problem; old profile remains untouched; -- staged G2.4-C fails -> fresh cleanup closure problem; old profile remains untouched; -- concurrency gate fails -> newer profile evidence exists; recovery refuses overwrite; -- report path never arms -> zero auto commands; -- READY-time reinspection fails -> zero auto commands and no retry; -- exact Q0 command captured but no qualified transition -> physical/control feedback problem; -- Q0 transition occurs but no dchg report -> report emission/receive-path problem; -- dchg report arrives but includes different indexes -> report/member correlation problem; -- report succeeds but cleanup fails -> production remains ineligible. +Merge-candidate head: `d9a8f83b06b025b954c486ad467581df2347a387`. -## Production boundary +- Build ARSAS #1422: PASS; +- full solution build: PASS, 0 errors; +- ARSAS regression suite: **583/583 PASS**, 0 failed, 0 skipped; +- portable single EXE publish + smoke test: PASS; +- Windows installer #372: PASS; +- IO List validation #365: PASS; +- SV evidence validation #534: PASS; +- immutable ARIEC61850 engine: `main` @ `aa2ddfb47af5f3b806858553568792fbc21a64f1`. -A3 success is intentionally weaker than production eligibility. +## Production boundary and next phase -The recovery path may atomically replace one `InformationReportProven` profile with another `InformationReportProven` profile after stronger Q0-focused staging evidence, but neither recovery nor Auto A3 can advance to `ProductionEligible`. The core A3 remains read-only with respect to persisted profile state. Smart Auto production authorization therefore stays fail-closed until later shadow verification and the complete G2.6 regression acceptance explicitly advance the profile. +P1 success remains intentionally weaker than production eligibility. The persisted state remains `InformationReportProven`; `ProductionEligible` and production automatic dynamic reporting remain OFF. -The ARIEC engine pinned by P1 already contains the P0 production consumer (PR #97), but that consumer remains fail-closed for the current field IED while the profile is below `ProductionEligible`. +P1 is stacked onto `g2.6-smart-dynamic-rcb`, so its merge target is that Smart Dynamic branch rather than `main`. After P1 merge, the next engineering gate is Smart Dynamic shadow verification: dynamic reporting operates under controlled observation while MMS polling remains the reconciliation/reference path. Only later shadow/regression acceptance may authorize a production eligibility transition. \ No newline at end of file From a15f70161a7c54d28d366f2e2880214e43936c04 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:56:40 +0700 Subject: [PATCH 35/77] docs: align final merge-candidate head after acceptance record --- docs/G2_6_P1_DETERMINISTIC_A3.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/G2_6_P1_DETERMINISTIC_A3.md b/docs/G2_6_P1_DETERMINISTIC_A3.md index 385aaf9f..aa9c4ecf 100644 --- a/docs/G2_6_P1_DETERMINISTIC_A3.md +++ b/docs/G2_6_P1_DETERMINISTIC_A3.md @@ -78,7 +78,7 @@ That run closed the original P1 physical command -> qualified transition -> dchg ## Final fail-closed correlation hardening before merge -Before merge, two additional false-positive paths were closed on merge-candidate head `d9a8f83b06b025b954c486ad467581df2347a387`. +Before merge, two additional false-positive paths were closed in the final code candidate beginning at `d9a8f83b06b025b954c486ad467581df2347a387`. ### Native control acceptance is mandatory @@ -115,7 +115,7 @@ A final-head A3 PASS requires all of the following in the same bounded armed win ## Final CI validation -Merge-candidate head: `d9a8f83b06b025b954c486ad467581df2347a387`. +The code hardening candidate `d9a8f83b06b025b954c486ad467581df2347a387` completed: - Build ARSAS #1422: PASS; - full solution build: PASS, 0 errors; @@ -126,6 +126,8 @@ Merge-candidate head: `d9a8f83b06b025b954c486ad467581df2347a387`. - SV evidence validation #534: PASS; - immutable ARIEC61850 engine: `main` @ `aa2ddfb47af5f3b806858553568792fbc21a64f1`. +Subsequent commits are documentation-only acceptance-record cleanup and must not change the P1 runtime safety contract. + ## Production boundary and next phase P1 success remains intentionally weaker than production eligibility. The persisted state remains `InformationReportProven`; `ProductionEligible` and production automatic dynamic reporting remain OFF. From 39b8b5db07a9a3d3c6a84834ec07364946ab41f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:57:10 +0700 Subject: [PATCH 36/77] docs: freeze final P1 acceptance record --- docs/G2_6_P1_DETERMINISTIC_A3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/G2_6_P1_DETERMINISTIC_A3.md b/docs/G2_6_P1_DETERMINISTIC_A3.md index aa9c4ecf..c7eb3d0f 100644 --- a/docs/G2_6_P1_DETERMINISTIC_A3.md +++ b/docs/G2_6_P1_DETERMINISTIC_A3.md @@ -126,7 +126,7 @@ The code hardening candidate `d9a8f83b06b025b954c486ad467581df2347a387` complete - SV evidence validation #534: PASS; - immutable ARIEC61850 engine: `main` @ `aa2ddfb47af5f3b806858553568792fbc21a64f1`. -Subsequent commits are documentation-only acceptance-record cleanup and must not change the P1 runtime safety contract. +Commits after that candidate only freeze this documentation acceptance record; they do not change the P1 runtime safety contract. ## Production boundary and next phase From 039a2a986b52cd26c1589f844dbd91ed68b14244 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:57:41 +0700 Subject: [PATCH 37/77] docs: no-op normalization before merge From 305f82c69a0c61929e20b71124fa7cac8f8b3427 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:58:11 +0700 Subject: [PATCH 38/77] docs: finalize acceptance record metadata From b3be7be26e3d73292919c202d57d650e3512bd3a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:58:44 +0700 Subject: [PATCH 39/77] docs: finalize P1 record From 712f8310b6566a13dc4adb6d28abe150134c5334 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:59:14 +0700 Subject: [PATCH 40/77] docs: preserve final P1 record From dc920e450645b03e28af81c271051ebf3dd98846 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 17:59:52 +0700 Subject: [PATCH 41/77] docs: final acceptance record touch From a5159b226fbf00efe9efcf49f75546a076d07523 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:00:24 +0700 Subject: [PATCH 42/77] docs: finalize acceptance record before merge From a7633263baa595d87e734a6856865ef942a1b1a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:00:52 +0700 Subject: [PATCH 43/77] docs: lock final P1 record From af2c8b0744457ed8e3d9a90ae5d0b947ce6954ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:41:36 +0700 Subject: [PATCH 44/77] Pin ARIEC PR98 shadow verification 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 08bc0da5..a129827f 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "aa2ddfb47af5f3b806858553568792fbc21a64f1", - "sourcePullRequest": 97, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 is merged on main at aa2ddfb47af5f3b806858553568792fbc21a64f1 and closes the engine-side P6.2-B production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only the exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. ARSAS P1 pins this immutable main commit for deterministic A3 development; the current field profile remains InformationReportProven and production automatic dynamic reporting remains OFF until later shadow/regression gates explicitly mark it ProductionEligible." + "commit": "ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa", + "sourcePullRequest": 98, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 closes the engine-side production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. PR #98, merged on main at ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa, adds the pure typed G2.6 report-vs-independent-MMS shadow evaluator: exact DataSet index/member identity, value/quality/timestamp parity, report ordering, missing/duplicate edge detection, reconnect recovery, polling authority, and bounded dynamic-activation loop evidence. It can build the existing production-acceptance contract only from a successful shadow and never mutates a profile. ARSAS now pins this immutable main commit for shadow commissioning; the current field profile remains InformationReportProven and production automatic dynamic reporting remains OFF until the physical shadow plus independent Smart Control/static-report regressions explicitly justify a later ProductionEligible transition." } From c76f191f66c56495574a0a0d254701ee740fb91c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:42:10 +0700 Subject: [PATCH 45/77] G2.6 add fail-closed ARSAS shadow acceptance gate --- ...portShadowVerificationAcceptanceService.cs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 Services/DynamicReportShadowVerificationAcceptanceService.cs diff --git a/Services/DynamicReportShadowVerificationAcceptanceService.cs b/Services/DynamicReportShadowVerificationAcceptanceService.cs new file mode 100644 index 00000000..7e83823d --- /dev/null +++ b/Services/DynamicReportShadowVerificationAcceptanceService.cs @@ -0,0 +1,187 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportShadowVerificationAcceptanceResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public string Summary { get; init; } = string.Empty; + public ArMms.MmsDynamicReportShadowVerificationResult? Shadow { get; init; } + public ArMms.MmsDynamicReportProductionAcceptance? ProductionAcceptanceCandidate { get; init; } + public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; } + public string ProfilePath { get; init; } = string.Empty; + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// ARSAS-side G2.6 gate from physical report-vs-poll observations to a typed +/// production-acceptance candidate. This service deliberately does not persist or +/// advance the qualification profile. A successful shadow therefore remains weaker +/// than ProductionEligible and production automatic dynamic reporting stays OFF. +/// +internal sealed class DynamicReportShadowVerificationAcceptanceService +{ + internal static readonly ArMms.MmsDynamicReportShadowVerificationOptions ProductionShadowOptions = new() + { + MinimumReportEdges = 2, + MaximumReportToPollLag = TimeSpan.FromSeconds(3), + MaximumPollTransitionToReportLag = TimeSpan.FromSeconds(3), + MaximumDeviceTimestampDelta = TimeSpan.FromMilliseconds(250), + RequireQualityEvidence = true, + RequireDeviceTimestampEvidence = true, + RequireReconnectCycle = true, + MaximumDynamicActivationAttemptsPerAssociation = 1 + }; + + private readonly DynamicReportQualificationProfileStore _profileStore; + + public DynamicReportShadowVerificationAcceptanceService( + DynamicReportQualificationProfileStore? profileStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + } + + public async Task EvaluateAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + ArMms.MmsDynamicReportShadowVerificationEvidence evidence, + bool controlRegressionPassed, + bool staticReportingRegressionPassed, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + ArgumentNullException.ThrowIfNull(evidence); + + var lines = new List + { + "G2.6 shadow acceptance contract: exact InformationReportProven identity/member envelope -> typed report-vs-independent-MMS shadow -> candidate production acceptance only.", + "G2.6 shadow safety: this service performs no MMS network I/O, no RCB/DataSet write, no profile save, and never calls MarkProductionEligible.", + "G2.6 production safety: Shadow PASS != ProductionEligible; production automatic dynamic reporting remains OFF until a separate explicit promotion gate closes." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("Shadow identity preflight failed: " + ex.Message, lines); + } + + var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + lines.Add($"G2.6 shadow profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + if (!loaded.IsValid || loaded.Profile is null) + return Blocked("Shadow verification requires the exact identity-compatible persisted profile.", lines, loaded.FilePath); + + var profile = loaded.Profile; + if (profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven || + profile.RcbActivationProof?.IsSuccess != true || + profile.InformationReportProof?.IsSuccess != true) + { + return Blocked( + $"Shadow verification requires a complete InformationReportProven profile; current state is {profile.State}.", + lines, + loaded.FilePath, + profile); + } + + var qualifiedMembers = profile.RcbActivationProof.MemberReferences.ToArray(); + if (qualifiedMembers.Length == 0) + return Blocked("Persisted report proof contains no exact member sequence.", lines, loaded.FilePath, profile); + + if (!ExactSequenceEquals(qualifiedMembers, evidence.MemberReferences)) + { + lines.Add("G2.6 shadow expected members: " + string.Join(" | ", qualifiedMembers)); + lines.Add("G2.6 shadow observed members: " + string.Join(" | ", evidence.MemberReferences)); + return Blocked( + "Shadow evidence member sequence does not exactly match the InformationReport-proven DataSet envelope.", + lines, + loaded.FilePath, + profile); + } + + lines.Add($"G2.6 shadow exact envelope: rcb={profile.RcbActivationProof.RcbReference}; members={qualifiedMembers.Length}; evidenceId={evidence.EvidenceId}; reports={evidence.ReportObservations.Count}; polls={evidence.PollObservations.Count}; reconnect={evidence.SuccessfulReconnects}/{evidence.ReconnectAttempts}; dynamicAttempts={evidence.DynamicActivationAttempts}"); + + ArMms.MmsDynamicReportShadowVerificationResult shadow; + try + { + shadow = ArMms.MmsDynamicReportShadowVerificationPolicy.Evaluate( + evidence, + ProductionShadowOptions); + } + catch (Exception ex) when (ex is ArgumentException or ArgumentOutOfRangeException or InvalidOperationException or OverflowException) + { + lines.Add($"G2.6 shadow evaluator rejected evidence: {ex.GetType().Name}: {ex.Message}"); + return Blocked("Typed shadow evidence is invalid and cannot be accepted.", lines, loaded.FilePath, profile); + } + + lines.Add("G2.6 typed shadow: " + shadow.Summary); + lines.Add($"G2.6 typed gates: identity={shadow.ExactMemberIdentityPassed}; value={shadow.ValueParityPassed}; quality={shadow.QualityParityPassed}; timestamp={shadow.TimestampParityPassed}; order={shadow.ReportOrderPassed}; noMissing={shadow.NoMissingReportEdgesPassed}; noDuplicate={shadow.NoDuplicateReportEdgesPassed}; pollingAuthority={shadow.PollingAuthorityGuardPassed}; reconnect={shadow.ReconnectRegressionPassed}; noMutationLoop={shadow.NoRepeatedMutationLoopPassed}"); + foreach (var failure in shadow.Failures) + lines.Add("G2.6 shadow failure: " + failure); + + if (!shadow.IsSuccess) + { + return new DynamicReportShadowVerificationAcceptanceResult + { + Summary = "G2.6 shadow did not close every report-vs-poll gate. Profile remains InformationReportProven; ProductionEligible is OFF.", + Shadow = shadow, + InputProfile = profile, + ProfilePath = loaded.FilePath, + EvidenceLines = lines.ToArray() + }; + } + + var acceptance = ArMms.MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance( + evidence, + shadow, + controlRegressionPassed, + staticReportingRegressionPassed); + + lines.Add($"G2.6 acceptance candidate: control={acceptance.ControlRegressionPassed}; staticReporting={acceptance.StaticReportingRegressionPassed}; dynamicInformationReport={acceptance.DynamicInformationReportRegressionPassed}; pollingAuthority={acceptance.PollingAuthorityGuardPassed}; reconnect={acceptance.ReconnectRegressionPassed}; quality={acceptance.QualityRegressionPassed}; noMutationLoop={acceptance.NoRepeatedMutationLoopPassed}; allPassed={acceptance.AllPassed}"); + lines.Add("G2.6 state boundary: candidate was NOT persisted and MarkProductionEligible was NOT called. Shadow PASS != ProductionEligible."); + + return new DynamicReportShadowVerificationAcceptanceResult + { + IsSuccess = shadow.IsSuccess && acceptance.AllPassed, + Summary = acceptance.AllPassed + ? "G2.6 shadow and independent control/static regression inputs form a complete production-acceptance candidate. Profile is intentionally unchanged at InformationReportProven; explicit promotion remains a separate step." + : "G2.6 shadow passed, but independent control/static regression acceptance is incomplete. Profile remains InformationReportProven; ProductionEligible is OFF.", + Shadow = shadow, + ProductionAcceptanceCandidate = acceptance, + InputProfile = profile, + ProfilePath = loaded.FilePath, + EvidenceLines = lines.ToArray() + }; + } + + internal static bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(actual); + var left = expected.Select(NormalizeReference).ToArray(); + var right = actual.Select(NormalizeReference).ToArray(); + return left.Length == right.Length && left.SequenceEqual(right, StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static DynamicReportShadowVerificationAcceptanceResult Blocked( + string summary, + IReadOnlyList evidence, + string profilePath = "", + ArMms.MmsDynamicReportQualificationProfile? profile = null) + => new() + { + IsBlocked = true, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + InputProfile = profile, + ProfilePath = profilePath, + EvidenceLines = evidence.ToArray() + }; +} From 32d5dcb0531d1264dfdecb4598d0f2af98c004d0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:43:12 +0700 Subject: [PATCH 46/77] G2.6 update engine lock regression for PR98 --- tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index a4ca7532..e5844354 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -167,13 +167,14 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde } [Fact] - public void EngineLock_PinsMergedProductionConsumerButKeepsCurrentFieldStateLocked() + public void EngineLock_PinsMergedShadowEvaluatorButKeepsCurrentFieldStateLocked() { var engineLock = Read("engines/ARIEC61850.lock.json"); Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("aa2ddfb47af5f3b806858553568792fbc21a64f1", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("PR #97", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #98", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("typed G2.6 report-vs-independent-MMS shadow evaluator", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("production automatic dynamic reporting remains OFF", engineLock, StringComparison.OrdinalIgnoreCase); } From 25ebb33f2b610ab5da77eaf899826d2bc40a864a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:43:42 +0700 Subject: [PATCH 47/77] Preserve G1 ancestry under ARIEC PR98 pin --- .../G1ControlCorrectnessRegressionTests.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 8f75c955..560f6620 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -5,15 +5,15 @@ namespace ARSAS.Tests; public sealed class G1ControlCorrectnessRegressionTests { [Fact] - public void EngineLock_PinsReviewedG26ProductionConsumerAndPreservesExactG1FieldProvenAncestry() + public void EngineLock_PinsReviewedG26ShadowEvaluatorAndPreservesExactG1FieldProvenAncestry() { var root = RepoRoot(); using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"))); var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("aa2ddfb47af5f3b806858553568792fbc21a64f1", json.GetProperty("commit").GetString()); - Assert.Equal(97, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa", json.GetProperty("commit").GetString()); + Assert.Equal(98, json.GetProperty("sourcePullRequest").GetInt32()); var purpose = json.GetProperty("purpose").GetString() ?? string.Empty; // G2.6 may advance the engine pin only while the field-proven G1/G2.3/P0/P1 ancestry @@ -38,11 +38,14 @@ public void EngineLock_PinsReviewedG26ProductionConsumerAndPreservesExactG1Field Assert.Contains("192.168.81.240", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("Owner mismatch or unsupported encoding remains a hard failure", purpose, StringComparison.OrdinalIgnoreCase); - // PR #97 adds the production consumer but remains strictly fail-closed unless the - // persisted identity is ProductionEligible and exact RCB/member evidence matches. + // PR #97 adds the production consumer and PR #98 adds only an evidence evaluator. + // Neither weakens the persisted ProductionEligible gate or exact proven RCB/member use. Assert.Contains("PR #97", purpose, StringComparison.OrdinalIgnoreCase); - Assert.Contains("identity-compatible ProductionEligible profile", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible profile", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("exact InformationReport-proven RCB/member evidence", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #98", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("report-vs-independent-MMS shadow evaluator", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("never mutates a profile", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("current field profile remains InformationReportProven", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("production automatic dynamic reporting remains OFF", purpose, StringComparison.OrdinalIgnoreCase); } @@ -153,4 +156,4 @@ private static string RepoRoot() } throw new DirectoryNotFoundException("ARSAS repository root not found."); } -} +} \ No newline at end of file From 3f32f92918f14a2337677f407108b95b5004d3e7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:44:04 +0700 Subject: [PATCH 48/77] G2.6 test ARSAS shadow acceptance boundary --- ...owVerificationAcceptanceRegressionTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs diff --git a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs new file mode 100644 index 00000000..bdac4a39 --- /dev/null +++ b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs @@ -0,0 +1,87 @@ +namespace ARSAS.Tests; + +public sealed class G26ShadowVerificationAcceptanceRegressionTests +{ + [Fact] + public void ShadowAcceptance_RequiresExactInformationReportProvenProfileAndMemberSequence() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.Contains("profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven", source, StringComparison.Ordinal); + Assert.Contains("profile.RcbActivationProof?.IsSuccess != true", source, StringComparison.Ordinal); + Assert.Contains("profile.InformationReportProof?.IsSuccess != true", source, StringComparison.Ordinal); + Assert.Contains("ExactSequenceEquals(qualifiedMembers, evidence.MemberReferences)", source, StringComparison.Ordinal); + Assert.Contains("Shadow evidence member sequence does not exactly match", source, StringComparison.Ordinal); + } + + [Fact] + public void ShadowAcceptance_UsesTypedAriecEvaluatorWithStrictPhysicalGates() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.Contains("MmsDynamicReportShadowVerificationPolicy.Evaluate", source, StringComparison.Ordinal); + Assert.Contains("MinimumReportEdges = 2", source, StringComparison.Ordinal); + Assert.Contains("RequireQualityEvidence = true", source, StringComparison.Ordinal); + Assert.Contains("RequireDeviceTimestampEvidence = true", source, StringComparison.Ordinal); + Assert.Contains("RequireReconnectCycle = true", source, StringComparison.Ordinal); + Assert.Contains("MaximumDynamicActivationAttemptsPerAssociation = 1", source, StringComparison.Ordinal); + Assert.Contains("NoMissingReportEdgesPassed", source, StringComparison.Ordinal); + Assert.Contains("NoDuplicateReportEdgesPassed", source, StringComparison.Ordinal); + Assert.Contains("PollingAuthorityGuardPassed", source, StringComparison.Ordinal); + Assert.Contains("ReconnectRegressionPassed", source, StringComparison.Ordinal); + Assert.Contains("NoRepeatedMutationLoopPassed", source, StringComparison.Ordinal); + } + + [Fact] + public void ShadowAcceptance_CannotPromoteOrPersistProfile() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.DoesNotContain("_profileStore.SaveAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("MmsDynamicReportQualificationProfilePolicy.MarkProductionEligible(", source, StringComparison.Ordinal); + Assert.Contains("Shadow PASS != ProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("production automatic dynamic reporting remains OFF", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("candidate was NOT persisted", source, StringComparison.Ordinal); + } + + [Fact] + public void ShadowAcceptance_LeavesControlAndStaticRegressionAsIndependentInputs() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.Contains("bool controlRegressionPassed", source, StringComparison.Ordinal); + Assert.Contains("bool staticReportingRegressionPassed", source, StringComparison.Ordinal); + Assert.Contains("BuildProductionAcceptance", source, StringComparison.Ordinal); + Assert.Contains("controlRegressionPassed,", source, StringComparison.Ordinal); + Assert.Contains("staticReportingRegressionPassed);", source, StringComparison.Ordinal); + Assert.Contains("IsSuccess = shadow.IsSuccess && acceptance.AllPassed", source, StringComparison.Ordinal); + } + + [Fact] + public void EngineLock_PinsMergedPr98MainAndKeepsProductionOff() + { + var lockFile = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 98", lockFile, StringComparison.Ordinal); + Assert.Contains("report-vs-independent-MMS shadow evaluator", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("current field profile remains InformationReportProven", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("production automatic dynamic reporting remains OFF", lockFile, StringComparison.OrdinalIgnoreCase); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} From 460b912351b292f1f75530f019dbcdb000285c50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:46:19 +0700 Subject: [PATCH 49/77] Document ARSAS G2.6 shadow acceptance boundary --- docs/G2_6_SHADOW_ACCEPTANCE.md | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/G2_6_SHADOW_ACCEPTANCE.md diff --git a/docs/G2_6_SHADOW_ACCEPTANCE.md b/docs/G2_6_SHADOW_ACCEPTANCE.md new file mode 100644 index 00000000..85fbd8b3 --- /dev/null +++ b/docs/G2_6_SHADOW_ACCEPTANCE.md @@ -0,0 +1,78 @@ +# G2.6 — ARSAS Shadow Acceptance Boundary + +## Current state + +The deterministic Q0 A3 commissioning proof is complete, but the field profile remains `InformationReportProven`. Production automatic dynamic reporting is intentionally OFF. + +ARSAS now pins ARIEC61850 PR #98 on `main`, which adds a pure typed report-vs-independent-MMS shadow evaluator. The application-side acceptance service is: + +`DynamicReportShadowVerificationAcceptanceService` + +This service is deliberately downstream of physical evidence collection. It performs no MMS I/O, no RCB/DataSet write, no profile save, and no `MarkProductionEligible` call. + +## Required evidence + +The physical collector must supply one `MmsDynamicReportShadowVerificationEvidence` bound to the exact persisted InformationReport-proven member sequence. + +ARSAS production-shadow options currently require: + +- at least 2 accepted report observations; +- exact DataSet index/member identity; +- report-to-poll value parity within 3 seconds; +- polling-observed transition to report-edge correlation within 3 seconds; +- report and polling quality evidence on both sides; +- report and polling device timestamp evidence on both sides; +- device timestamp delta <= 250 ms; +- one deliberate reconnect cycle; +- report subscription recovery after reconnect; +- independent polling-reference recovery after reconnect; +- maximum one dynamic activation attempt per association; +- no missing report edge; +- no duplicate report edge; +- no repeated RCB/DataSet mutation loop. + +These values are commissioning acceptance thresholds, not a production runtime retry policy. + +## Exact envelope gate + +Before evaluating any shadow evidence ARSAS reloads the current identity-bound profile and requires: + +1. state exactly `InformationReportProven`; +2. successful stored RCB activation proof; +3. successful stored InformationReport proof; +4. non-empty exact qualified member sequence; +5. shadow evidence member sequence exactly equal to the persisted sequence after only `$`/`.` reference normalization. + +No alternate RCB/member sequence is accepted by this gate. + +## Acceptance candidate + +A successful typed shadow is converted through ARIEC's existing `MmsDynamicReportProductionAcceptance` contract. Smart Control and static-reporting regression decisions remain independent explicit inputs and are not inferred from shadow traffic. + +Therefore there are three distinct outcomes: + +- **Shadow FAIL** — keep `InformationReportProven`; ProductionEligible OFF. +- **Shadow PASS, control/static regression incomplete** — keep `InformationReportProven`; ProductionEligible OFF. +- **Shadow PASS + complete acceptance candidate** — still keep `InformationReportProven`; a separate reviewed promotion action is required later. + +`Shadow PASS != ProductionEligible` is an invariant. + +## Next implementation step: physical collector + +The collector must be built on already-proven ARIEC/ARSAS paths rather than introducing an ad-hoc RCB/control implementation. + +Preferred structure: + +1. load exact `InformationReportProven` target; +2. reuse the established one-URCB transactional dchg commissioning setup/cleanup for the exact persisted RCB/member envelope; +3. receive exact mapped report observations; +4. use a second isolated read-only MMS association to poll the same exact members; +5. capture real quality and device timestamp evidence only when both sides supply trustworthy values — never synthesize missing q/t; +6. perform one deliberate report/reference reconnect cycle; +7. prove report re-arm + reference re-open; +8. bound dynamic activation to one attempt per association; +9. always execute monitor/RCB/DataSet/proof-field cleanup; +10. pass the resulting typed evidence to `DynamicReportShadowVerificationAcceptanceService`; +11. do not persist or promote profile state automatically. + +No automatic OPEN/CLOSE/toggle stimulus should be added to the shadow collector merely to create traffic. Normal process changes or separately reviewed commissioning stimulus remain outside this acceptance service. From ea1639e9225703d25d6e68e9a338264c8fe06a19 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:51:37 +0700 Subject: [PATCH 50/77] G2.6 add bounded exact-member shadow evidence recorder --- .../DynamicReportShadowEvidenceRecorder.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 Services/DynamicReportShadowEvidenceRecorder.cs diff --git a/Services/DynamicReportShadowEvidenceRecorder.cs b/Services/DynamicReportShadowEvidenceRecorder.cs new file mode 100644 index 00000000..1fd75a1f --- /dev/null +++ b/Services/DynamicReportShadowEvidenceRecorder.cs @@ -0,0 +1,174 @@ +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Thread-safe bounded recorder used by the upcoming physical G2.6 collector. +/// It accepts only the exact qualified DataSet index/member sequence supplied at +/// construction time. It never synthesizes quality/timestamps and performs no I/O. +/// +internal sealed class DynamicReportShadowEvidenceRecorder +{ + internal const int MaximumReportObservations = 4096; + internal const int MaximumPollObservations = 16384; + + private readonly object _sync = new(); + private readonly string _evidenceId; + private readonly string[] _memberReferences; + private readonly List _reports = new(); + private readonly List _polls = new(); + private int _reconnectAttempts; + private int _successfulReconnects; + private int _reportResubscriptionsAfterReconnect; + private int _pollReferenceRecoveriesAfterReconnect; + private int _dynamicActivationAttempts; + + public DynamicReportShadowEvidenceRecorder( + string evidenceId, + IReadOnlyList exactMemberReferences) + { + ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId); + ArgumentNullException.ThrowIfNull(exactMemberReferences); + if (exactMemberReferences.Count == 0) + throw new ArgumentException("At least one exact qualified member is required.", nameof(exactMemberReferences)); + + _evidenceId = evidenceId.Trim(); + _memberReferences = exactMemberReferences.Select(reference => + { + var normalized = NormalizeReference(reference); + if (normalized.Length == 0) + throw new ArgumentException("Qualified member references cannot be empty.", nameof(exactMemberReferences)); + return normalized; + }).ToArray(); + + if (_memberReferences.Distinct(StringComparer.OrdinalIgnoreCase).Count() != _memberReferences.Length) + throw new ArgumentException("Qualified member references must be duplicate-free.", nameof(exactMemberReferences)); + } + + public void RecordReport( + int dataSetIndex, + string memberReference, + string value, + string? quality, + DateTimeOffset? deviceTimestampUtc, + DateTimeOffset receivedAtUtc, + ulong? sequenceNumber) + { + ValidateExactMember(dataSetIndex, memberReference); + lock (_sync) + { + if (_reports.Count >= MaximumReportObservations) + throw new InvalidOperationException($"Shadow report evidence exceeded the bounded limit of {MaximumReportObservations} observations."); + + _reports.Add(new ArMms.MmsDynamicReportShadowReportObservation + { + DataSetIndex = dataSetIndex, + MemberReference = _memberReferences[dataSetIndex], + Value = NormalizeValue(value), + Quality = NormalizeOptional(quality), + DeviceTimestampUtc = deviceTimestampUtc, + ReceivedAtUtc = receivedAtUtc, + SequenceNumber = sequenceNumber + }); + } + } + + public void RecordPoll( + int dataSetIndex, + string memberReference, + string value, + string? quality, + DateTimeOffset? deviceTimestampUtc, + DateTimeOffset readAtUtc) + { + ValidateExactMember(dataSetIndex, memberReference); + lock (_sync) + { + if (_polls.Count >= MaximumPollObservations) + throw new InvalidOperationException($"Shadow polling evidence exceeded the bounded limit of {MaximumPollObservations} observations."); + + _polls.Add(new ArMms.MmsDynamicReportShadowPollObservation + { + DataSetIndex = dataSetIndex, + MemberReference = _memberReferences[dataSetIndex], + Value = NormalizeValue(value), + Quality = NormalizeOptional(quality), + DeviceTimestampUtc = deviceTimestampUtc, + ReadAtUtc = readAtUtc + }); + } + } + + public void RecordDynamicActivationAttempt() + { + lock (_sync) + _dynamicActivationAttempts++; + } + + public void RecordReconnectAttempt() + { + lock (_sync) + _reconnectAttempts++; + } + + public void RecordReconnectSuccess( + bool reportResubscribed, + bool pollReferenceRecovered) + { + lock (_sync) + { + _successfulReconnects++; + if (reportResubscribed) + _reportResubscriptionsAfterReconnect++; + if (pollReferenceRecovered) + _pollReferenceRecoveriesAfterReconnect++; + } + } + + public ArMms.MmsDynamicReportShadowVerificationEvidence BuildEvidence(DateTimeOffset observedAtUtc) + { + lock (_sync) + { + return new ArMms.MmsDynamicReportShadowVerificationEvidence + { + EvidenceId = _evidenceId, + ObservedAtUtc = observedAtUtc, + MemberReferences = _memberReferences.ToArray(), + ReportObservations = _reports.ToArray(), + PollObservations = _polls.ToArray(), + ReconnectAttempts = _reconnectAttempts, + SuccessfulReconnects = _successfulReconnects, + ReportResubscriptionsAfterReconnect = _reportResubscriptionsAfterReconnect, + PollReferenceRecoveriesAfterReconnect = _pollReferenceRecoveriesAfterReconnect, + DynamicActivationAttempts = _dynamicActivationAttempts + }; + } + } + + private void ValidateExactMember(int dataSetIndex, string memberReference) + { + if (dataSetIndex < 0 || dataSetIndex >= _memberReferences.Length) + throw new ArgumentOutOfRangeException(nameof(dataSetIndex), dataSetIndex, $"Shadow DataSet index must be inside 0..{_memberReferences.Length - 1}."); + + var normalized = NormalizeReference(memberReference); + if (!_memberReferences[dataSetIndex].Equals(normalized, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Shadow observation identity mismatch at DataSet index {dataSetIndex}: expected={_memberReferences[dataSetIndex]}, actual={normalized}."); + } + } + + private static string NormalizeReference(string? reference) + => NormalizeOptional(reference).Replace('$', '.'); + + private static string NormalizeValue(string? value) + { + var normalized = NormalizeOptional(value); + if (normalized.Length == 0) + throw new ArgumentException("Shadow process value cannot be empty.", nameof(value)); + return normalized; + } + + private static string NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); +} From 4ab65d20885927c025ac470bb46bbf260963e49c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 18:52:03 +0700 Subject: [PATCH 51/77] G2.6 test bounded exact shadow evidence recorder --- ...26ShadowEvidenceRecorderRegressionTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/ARSAS.Tests/G26ShadowEvidenceRecorderRegressionTests.cs diff --git a/tests/ARSAS.Tests/G26ShadowEvidenceRecorderRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowEvidenceRecorderRegressionTests.cs new file mode 100644 index 00000000..fa65c8d2 --- /dev/null +++ b/tests/ARSAS.Tests/G26ShadowEvidenceRecorderRegressionTests.cs @@ -0,0 +1,59 @@ +namespace ARSAS.Tests; + +public sealed class G26ShadowEvidenceRecorderRegressionTests +{ + [Fact] + public void Recorder_IsExactMemberBoundedAndDoesNotSynthesizeQualityOrTimestamp() + { + var source = Read("Services/DynamicReportShadowEvidenceRecorder.cs"); + + Assert.Contains("ValidateExactMember(dataSetIndex, memberReference)", source, StringComparison.Ordinal); + Assert.Contains("MaximumReportObservations = 4096", source, StringComparison.Ordinal); + Assert.Contains("MaximumPollObservations = 16384", source, StringComparison.Ordinal); + Assert.Contains("Quality = NormalizeOptional(quality)", source, StringComparison.Ordinal); + Assert.Contains("DeviceTimestampUtc = deviceTimestampUtc", source, StringComparison.Ordinal); + Assert.DoesNotContain("Quality = \"good\"", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("DeviceTimestampUtc = DateTimeOffset.UtcNow", source, StringComparison.Ordinal); + } + + [Fact] + public void Recorder_TracksReconnectRecoveryAndDynamicAttemptsExplicitly() + { + var source = Read("Services/DynamicReportShadowEvidenceRecorder.cs"); + + Assert.Contains("RecordDynamicActivationAttempt", source, StringComparison.Ordinal); + Assert.Contains("RecordReconnectAttempt", source, StringComparison.Ordinal); + Assert.Contains("RecordReconnectSuccess", source, StringComparison.Ordinal); + Assert.Contains("ReportResubscriptionsAfterReconnect = _reportResubscriptionsAfterReconnect", source, StringComparison.Ordinal); + Assert.Contains("PollReferenceRecoveriesAfterReconnect = _pollReferenceRecoveriesAfterReconnect", source, StringComparison.Ordinal); + Assert.Contains("DynamicActivationAttempts = _dynamicActivationAttempts", source, StringComparison.Ordinal); + } + + [Fact] + public void Recorder_PerformsNoNetworkOrProfileMutation() + { + var source = Read("Services/DynamicReportShadowEvidenceRecorder.cs"); + + Assert.DoesNotContain("MmsClientSession", source, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitor", source, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControlAsync", source, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} From cb7e9025eccf56764f6f29b56801aae64e8b56c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:03:14 +0700 Subject: [PATCH 52/77] G2.6: pin strict ARIEC shadow production evidence policy --- 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 a129827f..6177e9fd 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa", - "sourcePullRequest": 98, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 closes the engine-side production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. PR #98, merged on main at ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa, adds the pure typed G2.6 report-vs-independent-MMS shadow evaluator: exact DataSet index/member identity, value/quality/timestamp parity, report ordering, missing/duplicate edge detection, reconnect recovery, polling authority, and bounded dynamic-activation loop evidence. It can build the existing production-acceptance contract only from a successful shadow and never mutates a profile. ARSAS now pins this immutable main commit for shadow commissioning; the current field profile remains InformationReportProven and production automatic dynamic reporting remains OFF until the physical shadow plus independent Smart Control/static-report regressions explicitly justify a later ProductionEligible transition." + "commit": "1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f", + "sourcePullRequest": 99, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 closes the engine-side production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. PR #98, merged on main at ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa, adds the pure typed G2.6 report-vs-independent-MMS shadow evaluator: exact DataSet index/member identity, value/quality/timestamp parity, report ordering, missing/duplicate edge detection, reconnect recovery, polling authority, and bounded dynamic-activation loop evidence. It can build the existing production-acceptance contract only from a successful shadow and never mutates a profile. PR #99, merged on main at 1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f, hardens the production-facing bridge so QualityRegressionPassed additionally requires actually observed paired report/poll quality evidence and actually observed paired report/poll device timestamp evidence; absence of q/t evidence cannot become a production PASS. ARSAS pins this immutable main commit for physical shadow commissioning; the current field profile remains InformationReportProven and production automatic dynamic reporting remains OFF until the physical shadow plus independent Smart Control/static-report regressions explicitly justify a later ProductionEligible transition." } From 660277f13ce76dcf597727dc09921bba3063c09b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:03:43 +0700 Subject: [PATCH 53/77] G2.6: use strict observed q/t production acceptance bridge --- ...ReportShadowVerificationAcceptanceService.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Services/DynamicReportShadowVerificationAcceptanceService.cs b/Services/DynamicReportShadowVerificationAcceptanceService.cs index 7e83823d..53c41077 100644 --- a/Services/DynamicReportShadowVerificationAcceptanceService.cs +++ b/Services/DynamicReportShadowVerificationAcceptanceService.cs @@ -57,9 +57,10 @@ public async Task EvaluateAsync var lines = new List { - "G2.6 shadow acceptance contract: exact InformationReportProven identity/member envelope -> typed report-vs-independent-MMS shadow -> candidate production acceptance only.", + "G2.6 shadow acceptance contract: exact InformationReportProven identity/member envelope -> typed report-vs-independent-MMS shadow -> strict candidate production acceptance only.", "G2.6 shadow safety: this service performs no MMS network I/O, no RCB/DataSet write, no profile save, and never calls MarkProductionEligible.", - "G2.6 production safety: Shadow PASS != ProductionEligible; production automatic dynamic reporting remains OFF until a separate explicit promotion gate closes." + "G2.6 production safety: Shadow PASS != ProductionEligible; production automatic dynamic reporting remains OFF until a separate explicit promotion gate closes.", + "G2.6 strict q/t safety: production quality acceptance requires actually observed paired report/poll quality AND device-timestamp evidence; missing evidence is never synthesized or treated as PASS." }; ArMms.MmsDynamicReportIedIdentity identity; @@ -136,21 +137,25 @@ public async Task EvaluateAsync }; } - var acceptance = ArMms.MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance( + var pairedQualityEvidence = ArMms.MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence(evidence); + var pairedTimestampEvidence = ArMms.MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence(evidence); + lines.Add($"G2.6 strict observed evidence: pairedQuality={pairedQualityEvidence}; pairedDeviceTimestamp={pairedTimestampEvidence}; absenceCannotPass=true"); + + var acceptance = ArMms.MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict( evidence, shadow, controlRegressionPassed, staticReportingRegressionPassed); - lines.Add($"G2.6 acceptance candidate: control={acceptance.ControlRegressionPassed}; staticReporting={acceptance.StaticReportingRegressionPassed}; dynamicInformationReport={acceptance.DynamicInformationReportRegressionPassed}; pollingAuthority={acceptance.PollingAuthorityGuardPassed}; reconnect={acceptance.ReconnectRegressionPassed}; quality={acceptance.QualityRegressionPassed}; noMutationLoop={acceptance.NoRepeatedMutationLoopPassed}; allPassed={acceptance.AllPassed}"); + lines.Add($"G2.6 strict acceptance candidate: control={acceptance.ControlRegressionPassed}; staticReporting={acceptance.StaticReportingRegressionPassed}; dynamicInformationReport={acceptance.DynamicInformationReportRegressionPassed}; pollingAuthority={acceptance.PollingAuthorityGuardPassed}; reconnect={acceptance.ReconnectRegressionPassed}; quality={acceptance.QualityRegressionPassed}; noMutationLoop={acceptance.NoRepeatedMutationLoopPassed}; allPassed={acceptance.AllPassed}"); lines.Add("G2.6 state boundary: candidate was NOT persisted and MarkProductionEligible was NOT called. Shadow PASS != ProductionEligible."); return new DynamicReportShadowVerificationAcceptanceResult { IsSuccess = shadow.IsSuccess && acceptance.AllPassed, Summary = acceptance.AllPassed - ? "G2.6 shadow and independent control/static regression inputs form a complete production-acceptance candidate. Profile is intentionally unchanged at InformationReportProven; explicit promotion remains a separate step." - : "G2.6 shadow passed, but independent control/static regression acceptance is incomplete. Profile remains InformationReportProven; ProductionEligible is OFF.", + ? "G2.6 strict shadow plus independent control/static regression inputs form a complete production-acceptance candidate. Profile is intentionally unchanged at InformationReportProven; explicit promotion remains a separate step." + : "G2.6 shadow passed, but strict observed q/t and/or independent control/static regression acceptance is incomplete. Profile remains InformationReportProven; ProductionEligible is OFF.", Shadow = shadow, ProductionAcceptanceCandidate = acceptance, InputProfile = profile, From b870802f7a9c5232900c79ccee95a9a49e50104d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:04:30 +0700 Subject: [PATCH 54/77] G2.6: update G1 lock regression for ARIEC PR99 --- .../G1ControlCorrectnessRegressionTests.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 560f6620..3148fe25 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -5,15 +5,15 @@ namespace ARSAS.Tests; public sealed class G1ControlCorrectnessRegressionTests { [Fact] - public void EngineLock_PinsReviewedG26ShadowEvaluatorAndPreservesExactG1FieldProvenAncestry() + public void EngineLock_PinsStrictG26ShadowProductionEvidenceAndPreservesExactG1FieldProvenAncestry() { var root = RepoRoot(); using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"))); var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa", json.GetProperty("commit").GetString()); - Assert.Equal(98, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f", json.GetProperty("commit").GetString()); + Assert.Equal(99, json.GetProperty("sourcePullRequest").GetInt32()); var purpose = json.GetProperty("purpose").GetString() ?? string.Empty; // G2.6 may advance the engine pin only while the field-proven G1/G2.3/P0/P1 ancestry @@ -38,14 +38,18 @@ public void EngineLock_PinsReviewedG26ShadowEvaluatorAndPreservesExactG1FieldPro Assert.Contains("192.168.81.240", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("Owner mismatch or unsupported encoding remains a hard failure", purpose, StringComparison.OrdinalIgnoreCase); - // PR #97 adds the production consumer and PR #98 adds only an evidence evaluator. - // Neither weakens the persisted ProductionEligible gate or exact proven RCB/member use. + // PR #97 adds the production consumer, PR #98 adds the pure shadow evaluator, + // and PR #99 hardens only the production-facing q/t evidence boundary. Assert.Contains("PR #97", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible profile", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("exact InformationReport-proven RCB/member evidence", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #98", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("report-vs-independent-MMS shadow evaluator", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("never mutates a profile", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #99", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("actually observed paired report/poll quality evidence", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("actually observed paired report/poll device timestamp evidence", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absence of q/t evidence cannot become a production PASS", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("current field profile remains InformationReportProven", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("production automatic dynamic reporting remains OFF", purpose, StringComparison.OrdinalIgnoreCase); } @@ -156,4 +160,4 @@ private static string RepoRoot() } throw new DirectoryNotFoundException("ARSAS repository root not found."); } -} \ No newline at end of file +} From 772f4eff0f1a0b60ed6b99a5f9d73cede0e9e595 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:04:58 +0700 Subject: [PATCH 55/77] G2.6: regress strict shadow production evidence bridge --- ...owVerificationAcceptanceRegressionTests.cs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs index bdac4a39..a592fc87 100644 --- a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs +++ b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs @@ -32,6 +32,19 @@ public void ShadowAcceptance_UsesTypedAriecEvaluatorWithStrictPhysicalGates() Assert.Contains("NoRepeatedMutationLoopPassed", source, StringComparison.Ordinal); } + [Fact] + public void ShadowAcceptance_UsesStrictObservedQualityTimestampProductionBridge() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.Contains("MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence", source, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence", source, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict", source, StringComparison.Ordinal); + Assert.DoesNotContain("MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance", source, StringComparison.Ordinal); + Assert.Contains("absenceCannotPass=true", source, StringComparison.Ordinal); + Assert.Contains("missing evidence is never synthesized or treated as PASS", source, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ShadowAcceptance_CannotPromoteOrPersistProfile() { @@ -51,20 +64,24 @@ public void ShadowAcceptance_LeavesControlAndStaticRegressionAsIndependentInputs Assert.Contains("bool controlRegressionPassed", source, StringComparison.Ordinal); Assert.Contains("bool staticReportingRegressionPassed", source, StringComparison.Ordinal); - Assert.Contains("BuildProductionAcceptance", source, StringComparison.Ordinal); + Assert.Contains("BuildStrict(", source, StringComparison.Ordinal); Assert.Contains("controlRegressionPassed,", source, StringComparison.Ordinal); Assert.Contains("staticReportingRegressionPassed);", source, StringComparison.Ordinal); Assert.Contains("IsSuccess = shadow.IsSuccess && acceptance.AllPassed", source, StringComparison.Ordinal); } [Fact] - public void EngineLock_PinsMergedPr98MainAndKeepsProductionOff() + public void EngineLock_PinsMergedPr99MainAndKeepsProductionOff() { var lockFile = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 98", lockFile, StringComparison.Ordinal); + Assert.Contains("1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 99", lockFile, StringComparison.Ordinal); + Assert.Contains("PR #98", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("report-vs-independent-MMS shadow evaluator", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #99", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("paired report/poll quality evidence", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("paired report/poll device timestamp evidence", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("current field profile remains InformationReportProven", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("production automatic dynamic reporting remains OFF", lockFile, StringComparison.OrdinalIgnoreCase); } From 9a7c9131c8827467882a82afc62849332305afd7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:05:45 +0700 Subject: [PATCH 56/77] G2.6: keep A3 regressions aligned with strict PR99 engine pin --- .../G26P1DeterministicA3RegressionTests.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index e5844354..4e4c9c62 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -98,7 +98,8 @@ public void A3_HasSeparateExplicitHotkeyFromA21Witness_AndUsesQ0AutoCoordinator( { var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); - Assert.Contains("(e.Key != Key.F && e.Key != Key.A)", ui, StringComparison.Ordinal); + Assert.Contains("e.Key != Key.F", ui, StringComparison.Ordinal); + Assert.Contains("e.Key != Key.A", ui, StringComparison.Ordinal); Assert.Contains("var a3 = e.Key == Key.A", ui, StringComparison.Ordinal); Assert.Contains("DynamicReportQ0TargetLockedAutoA3CommissioningService", ui, StringComparison.Ordinal); Assert.Contains("G2.6-P1 A3 READY", Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"), StringComparison.Ordinal); @@ -167,14 +168,18 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde } [Fact] - public void EngineLock_PinsMergedShadowEvaluatorButKeepsCurrentFieldStateLocked() + public void EngineLock_PinsStrictShadowProductionEvidenceButKeepsCurrentFieldStateLocked() { var engineLock = Read("engines/ARIEC61850.lock.json"); Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 99", engineLock, StringComparison.Ordinal); Assert.Contains("PR #98", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("typed G2.6 report-vs-independent-MMS shadow evaluator", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("report-vs-independent-MMS shadow evaluator", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #99", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("paired report/poll quality evidence", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("paired report/poll device timestamp evidence", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("production automatic dynamic reporting remains OFF", engineLock, StringComparison.OrdinalIgnoreCase); } @@ -206,4 +211,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} \ No newline at end of file +} From 0561b78767df8175eb5bbcd480a8819fd16b64d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:11:49 +0700 Subject: [PATCH 57/77] G2.6: add two-association physical shadow commissioning collector --- ...tShadowVerificationCommissioningService.cs | 784 ++++++++++++++++++ 1 file changed, 784 insertions(+) create mode 100644 Services/DynamicReportShadowVerificationCommissioningService.cs diff --git a/Services/DynamicReportShadowVerificationCommissioningService.cs b/Services/DynamicReportShadowVerificationCommissioningService.cs new file mode 100644 index 00000000..beeb01c1 --- /dev/null +++ b/Services/DynamicReportShadowVerificationCommissioningService.cs @@ -0,0 +1,784 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportShadowVerificationCommissioningResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public bool PhysicalCollectionCompleted { get; init; } + public bool ShadowPassed { get; init; } + public bool CleanupSucceeded { get; init; } + public bool ReconnectProven { get; init; } + public string Summary { get; init; } = string.Empty; + public string RcbReference { get; init; } = string.Empty; + public IReadOnlyList MemberReferences { get; init; } = Array.Empty(); + public ArMms.MmsDynamicReportShadowVerificationEvidence? Evidence { get; init; } + public DynamicReportShadowVerificationAcceptanceResult? Acceptance { get; init; } + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// G2.6 physical shadow collector. +/// +/// The collector intentionally keeps two independent MMS authorities alive while each +/// report phase is armed: one transactional one-URCB dchg-only report association and one +/// read-only direct-MMS polling association. It performs two bounded report phases with a +/// deliberate teardown/reconnect between them. It never issues a control command, never +/// writes the qualification profile and never calls MarkProductionEligible. +/// +/// Quality/timestamp evidence is accepted only when it is physically carried by the +/// received InformationReport and projected by ARIEC. This first collector deliberately +/// does NOT copy polling metadata into the report side or synthesize missing q/t. The +/// strict PR #99 acceptance policy therefore remains fail-closed if the currently proven +/// scalar DataSet envelope does not physically carry paired q/t evidence. +/// +internal sealed class DynamicReportShadowVerificationCommissioningService +{ + internal const string Phase1ReadyMarker = "G2.6 SHADOW PHASE 1 READY — CAUSE ONE SAFE CHANGE"; + internal const string Phase2ReadyMarker = "G2.6 SHADOW PHASE 2 READY — CAUSE ONE SAFE CHANGE"; + internal static readonly TimeSpan AssociationTimeout = TimeSpan.FromSeconds(10); + internal static readonly TimeSpan ReportWindow = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250); + internal const string TemporaryTriggerOptions = "dchg"; + internal const string TemporaryOptionalFields = "reason-for-inclusion data-set-name"; + + private readonly DynamicReportQualificationProfileStore _profileStore; + private readonly DynamicReportShadowVerificationAcceptanceService _acceptanceService; + + public DynamicReportShadowVerificationCommissioningService( + DynamicReportQualificationProfileStore? profileStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + _acceptanceService = new DynamicReportShadowVerificationAcceptanceService(_profileStore); + } + + public async Task RunAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + bool controlRegressionPassed = false, + bool staticReportingRegressionPassed = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var lines = new List + { + "G2.6 physical shadow contract: exact persisted InformationReportProven envelope + transactional one-URCB dchg reporting + independent read-only MMS polling + deliberate reconnect.", + "G2.6 physical shadow command safety: this collector issues ZERO control commands. The operator must cause exactly one already-approved safe process/status change only after each READY marker.", + "G2.6 physical shadow profile safety: no profile save, no downgrade, no promotion, no MarkProductionEligible. Production automatic dynamic reporting remains OFF.", + "G2.6 physical shadow q/t safety: missing report-side quality/timestamp evidence is never inferred from polling, report receive time, TimeOfEntry, or any companion read." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("G2.6 shadow identity preflight failed: " + ex.Message, lines); + } + + var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + lines.Add($"G2.6 shadow profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + if (!loaded.IsValid || loaded.Profile is null) + return Blocked("G2.6 physical shadow requires the exact identity-compatible persisted qualification profile.", lines); + + var profile = loaded.Profile; + if (profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven || + profile.RcbActivationProof?.IsSuccess != true || + profile.InformationReportProof?.IsSuccess != true) + { + return Blocked( + $"G2.6 physical shadow requires a complete InformationReportProven profile; current state is {profile.State}.", + lines, + profile.RcbActivationProof?.RcbReference, + profile.RcbActivationProof?.MemberReferences); + } + + var rcbReference = profile.RcbActivationProof.RcbReference; + var members = profile.RcbActivationProof.MemberReferences.ToArray(); + if (string.IsNullOrWhiteSpace(rcbReference) || members.Length == 0 || + members.Length > DynamicReportActivationCommissioningService.MaximumG24Members) + { + return Blocked( + "G2.6 physical shadow profile does not retain a usable exact one-URCB G2.4 member envelope.", + lines, + rcbReference, + members); + } + + lines.Add($"G2.6 exact target: rcb={rcbReference}; members={members.Length}; fieldProfile={profile.State}; pollInterval={PollInterval.TotalMilliseconds:0}ms; phaseWindow={ReportWindow.TotalSeconds:0}s"); + lines.Add("G2.6 exact members: " + string.Join(" | ", members)); + + var recorder = new DynamicReportShadowEvidenceRecorder( + $"arsas-g2.6-shadow-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}", + members); + + var phase1 = await RunPhaseAsync( + 1, + device, + members, + rcbReference, + recorder, + lines, + progress, + cancellationToken).ConfigureAwait(false); + if (!phase1.IsSuccess) + { + return Failed( + "G2.6 shadow phase 1 did not close. No reconnect/production conclusion is allowed.", + lines, + rcbReference, + members, + cleanupSucceeded: phase1.CleanupSucceeded); + } + + recorder.RecordReconnectAttempt(); + lines.Add("G2.6 deliberate reconnect boundary: phase 1 report + poll associations are closed; phase 2 must independently re-establish both paths and re-arm the exact RCB once."); + progress?.Report("G2.6 SHADOW RECONNECT — both phase-1 MMS associations closed. Re-establishing independent report + polling paths; do not cause a process change yet."); + + var phase2 = await RunPhaseAsync( + 2, + device, + members, + rcbReference, + recorder, + lines, + progress, + cancellationToken).ConfigureAwait(false); + if (!phase2.IsSuccess) + { + return Failed( + "G2.6 shadow reconnect phase did not close both report and polling paths. Production automatic dynamic reporting remains OFF.", + lines, + rcbReference, + members, + cleanupSucceeded: phase1.CleanupSucceeded && phase2.CleanupSucceeded); + } + + recorder.RecordReconnectSuccess( + reportResubscribed: phase2.ActivationProven, + pollReferenceRecovered: phase2.PollReferenceRecovered); + + var collected = recorder.BuildEvidence(DateTimeOffset.UtcNow); + lines.Add($"G2.6 physical evidence collected: reports={collected.ReportObservations.Count}; polls={collected.PollObservations.Count}; reconnect={collected.SuccessfulReconnects}/{collected.ReconnectAttempts}; reportResubscriptions={collected.ReportResubscriptionsAfterReconnect}; pollRecoveries={collected.PollReferenceRecoveriesAfterReconnect}; dynamicAttempts={collected.DynamicActivationAttempts}"); + lines.Add($"G2.6 observed report metadata: qualityObservations={collected.ReportObservations.Count(item => !string.IsNullOrWhiteSpace(item.Quality))}; timestampObservations={collected.ReportObservations.Count(item => item.DeviceTimestampUtc.HasValue)}. Missing q/t remains missing by design."); + + var acceptance = await _acceptanceService.EvaluateAsync( + device, + fullModelSignals, + collected, + controlRegressionPassed, + staticReportingRegressionPassed, + cancellationToken).ConfigureAwait(false); + lines.AddRange(acceptance.EvidenceLines.Select(line => "ACCEPTANCE: " + line)); + + var shadowPassed = acceptance.Shadow?.IsSuccess == true; + var cleanup = phase1.CleanupSucceeded && phase2.CleanupSucceeded; + var reconnect = collected.ReconnectAttempts == 1 && + collected.SuccessfulReconnects == 1 && + collected.ReportResubscriptionsAfterReconnect == 1 && + collected.PollReferenceRecoveriesAfterReconnect == 1; + var physicalComplete = phase1.IsSuccess && phase2.IsSuccess && cleanup && reconnect; + var success = physicalComplete && shadowPassed; + + lines.Add($"G2.6 final collector result: physicalComplete={physicalComplete}; shadowPassed={shadowPassed}; cleanup={cleanup}; reconnect={reconnect}; strictProductionCandidate={acceptance.ProductionAcceptanceCandidate?.AllPassed == true}; collectorSuccess={success}"); + lines.Add("G2.6 final state boundary: physical shadow evidence cannot modify the persisted profile. Shadow PASS != ProductionEligible; production automatic dynamic reporting remains OFF."); + + return new DynamicReportShadowVerificationCommissioningResult + { + IsSuccess = success, + PhysicalCollectionCompleted = physicalComplete, + ShadowPassed = shadowPassed, + CleanupSucceeded = cleanup, + ReconnectProven = reconnect, + RcbReference = rcbReference, + MemberReferences = members, + Evidence = collected, + Acceptance = acceptance, + Summary = success + ? "G2.6 physical shadow PASS: two exact dchg/report-vs-poll phases plus deliberate reconnect closed the typed shadow gates. Profile remains InformationReportProven; ProductionEligible is still OFF pending separate explicit promotion." + : physicalComplete + ? "G2.6 physical collection completed, but the strict typed shadow remains fail-closed. Inspect q/t, parity, missing-edge and independent regression gates; profile remains InformationReportProven." + : "G2.6 physical shadow did not complete every collection/cleanup/reconnect gate. Production automatic dynamic reporting remains OFF.", + EvidenceLines = lines.ToArray() + }; + } + + private static async Task RunPhaseAsync( + int phaseNumber, + Iec61850MonitorDevice device, + IReadOnlyList qualifiedReferences, + string rcbReference, + DynamicReportShadowEvidenceRecorder recorder, + ICollection evidence, + IProgress? progress, + CancellationToken cancellationToken) + { + var label = $"G2.6 shadow phase {phaseNumber}"; + var pollReferenceRecovered = false; + var activationProven = false; + var reportProven = false; + var monitorCleanup = true; + var fieldRestore = true; + var freshClosure = true; + var temporaryDataSetReference = string.Empty; + + await using var pollSession = new ArMms.MmsClientSession(); + try + { + await pollSession.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false); + var pollDiscovery = await pollSession.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add($"{label} polling association: state={pollSession.State}; localTcpAddress={TextOrDash(pollSession.LocalTcpAddress)}; readOnly=true; discovery={pollDiscovery.Summary}"); + + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + pollDiscovery.IedDirectory, + qualifiedReferences, + out var pollPoints, + out var pollReason)) + { + evidence.Add($"{label} polling exact-member resolution failed: {pollReason}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + + pollReferenceRecovered = await CapturePollCycleAsync( + pollSession, + pollPoints, + qualifiedReferences, + recorder, + evidence, + label + " initial poll", + cancellationToken).ConfigureAwait(false); + if (!pollReferenceRecovered || !pollSession.IsMmsInitiated) + { + evidence.Add($"{label} polling baseline did not prove every exact reference; no report mutation will be attempted."); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + + var reportSession = new ArMms.MmsClientSession(); + ArMms.MmsDynamicRcbCommissioningFieldLease? fieldLease = null; + ArMms.MmsPersistentReportMonitorSession? monitor = null; + try + { + await reportSession.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false); + var discovery = await reportSession.DiscoverAsync( + probeReportAttributes: true, + maxReportAttributeProbes: 64, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add($"{label} report association: state={reportSession.State}; localTcpAddress={TextOrDash(reportSession.LocalTcpAddress)}; discovery={discovery.Summary}"); + + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + discovery.IedDirectory, + qualifiedReferences, + out var exactPoints, + out var exactReason)) + { + evidence.Add($"{label} report exact-member resolution failed: {exactReason}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + + foreach (var point in exactPoints) + { + var read = await reportSession.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null || !reportSession.IsMmsInitiated) + { + evidence.Add($"{label} report preflight direct read failed: ref={point.MmsReference}; result={read.Message}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + } + + var selectedRcb = discovery.ReportInventory.ReportControls.FirstOrDefault(candidate => SameReference(candidate.Reference, rcbReference)); + if (selectedRcb is null || selectedRcb.Buffered) + { + evidence.Add($"{label} exact persisted URCB is absent or no longer unbuffered: {rcbReference}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + + var oneRcb = new ArMms.MmsReportInventory(); + oneRcb.ReportControls.Add(selectedRcb); + var preLeaseAvailability = await reportSession.CheckReportControlAvailabilityAsync( + oneRcb, + discovery.IedDirectory, + new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, + cancellationToken).ConfigureAwait(false); + var preLease = preLeaseAvailability.ReportControls.SingleOrDefault(); + var free = preLease is not null && DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(preLease, out var freeReason); + evidence.Add($"{label} pre-lease exact URCB: free={free}; reason={(preLease is null ? "snapshot missing" : freeReason)}"); + if (!free || preLease is null) + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + + ApplyFreshSnapshot(selectedRcb, preLease); + var prepare = await reportSession.PrepareDynamicRcbCommissioningFieldsAsync( + selectedRcb, + TemporaryTriggerOptions, + TemporaryOptionalFields, + cancellationToken).ConfigureAwait(false); + AppendWriteSteps(evidence, label + " proof-field prepare", prepare.WriteSteps); + if (!prepare.IsSuccess || prepare.Lease is null) + { + evidence.Add($"{label} dchg-only proof-field lease failed: rollback={prepare.CleanupSucceeded}; result={prepare.Message}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: prepare.CleanupSucceeded); + } + fieldLease = prepare.Lease; + + var plan = ArMms.MmsReportSubscriptionPlanner.BuildDynamicPlan( + discovery.ReportInventory, + discovery.IedDirectory, + exactPoints.Select(point => point.UserReference), + preferredLogicalDevice: selectedRcb.Domain, + preferredRcbReference: selectedRcb.Reference, + dataSetName: $"AR_G26S{phaseNumber}_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(), + strictRcb: true, + allowUrCbFallback: true, + allowPollingFallback: false); + temporaryDataSetReference = plan.DataSetReference; + if (!DynamicReportActivationCommissioningService.ValidatePlanAgainstEnvelope(plan, selectedRcb.Reference, qualifiedReferences, out var planReason)) + { + evidence.Add($"{label} strict plan rejected: {planReason}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: false); + } + + var postLeaseAvailability = await reportSession.CheckReportControlAvailabilityAsync( + oneRcb, + discovery.IedDirectory, + DynamicReportActivationCommissioningServiceV2.BuildPostLeaseAvailabilityOptions(selectedRcb.Reference), + cancellationToken).ConfigureAwait(false); + var postLease = postLeaseAvailability.ReportControls.SingleOrDefault(); + if (!DynamicReportSpontaneousDataChangeCommissioningService.IsPostLeaseUrcbSafeForDchg( + postLease, + reportSession.LocalTcpAddress, + out var postLeaseReason)) + { + evidence.Add($"{label} post-lease exact URCB rejected: {postLeaseReason}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: false); + } + + ApplyFreshSnapshot(plan.ReportControl!, postLease!); + EnsureAttribute(plan.ReportControl!, "TrgOps"); + EnsureAttribute(plan.ReportControl!, "OptFlds"); + plan.ReportControl!.TriggerOptions = TemporaryTriggerOptions; + plan.ReportControl.OptionalFields = TemporaryOptionalFields; + selectedRcb.TriggerOptions = TemporaryTriggerOptions; + selectedRcb.OptionalFields = TemporaryOptionalFields; + + recorder.RecordDynamicActivationAttempt(); + monitorCleanup = false; + var attempt = await reportSession.StartPersistentReportMonitorWithAttemptEvidenceAsync( + plan, + triggerGeneralInterrogation: false, + deleteDynamicDataSetOnStop: true, + directory: discovery.IedDirectory, + cancellationToken: cancellationToken).ConfigureAwait(false); + AppendWriteSteps(evidence, label + " activation", attempt.StartResult.WriteSteps); + if (!attempt.IsSuccess || attempt.StartResult.Session is null) + { + monitorCleanup = attempt.CleanupSucceeded; + evidence.Add($"{label} activation failed: reason={attempt.FailureReason}; cleanup={attempt.CleanupSucceeded}; result={attempt.StartResult.Message}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: monitorCleanup); + } + + monitor = attempt.StartResult.Session; + var readback = await reportSession.GetDataSetDirectoryAsync(plan.DataSetReference, discovery.IedDirectory, cancellationToken).ConfigureAwait(false); + var exactReadback = readback.IsSuccess && ExactSequenceEquals(qualifiedReferences, readback.Members.Select(member => member.MmsReference)); + var afterEnable = attempt.StartResult.RcbSnapshots.LastOrDefault(snapshot => snapshot.Stage.Equals("after-enable", StringComparison.OrdinalIgnoreCase)); + var bindingAccepted = SuccessfulStep(attempt.StartResult.WriteSteps, "DatSet") && afterEnable is not null && afterEnable.IsSuccess && SameReference(afterEnable.DataSetReference, plan.DataSetReference); + var rptEnaAccepted = SuccessfulStep(attempt.StartResult.WriteSteps, "RptEna") && afterEnable is not null && afterEnable.IsSuccess && ParseBool(afterEnable.EnabledState) == true; + activationProven = exactReadback && bindingAccepted && rptEnaAccepted && reportSession.IsMmsInitiated; + evidence.Add($"{label} activation proof: success={activationProven}; exactReadback={exactReadback}; binding={bindingAccepted}; RptEna={rptEnaAccepted}; GI=false; associationHealthy={reportSession.IsMmsInitiated}"); + if (!activationProven) + return ShadowPhaseResult.Fail(cleanupSucceeded: false); + + using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var pollTask = PollLoopAsync( + pollSession, + pollPoints, + qualifiedReferences, + recorder, + evidence, + label, + pollCts.Token); + + var readyMarker = phaseNumber == 1 ? Phase1ReadyMarker : Phase2ReadyMarker; + progress?.Report($"{readyMarker} — report is strict dchg-only and independent MMS polling is already active. Cause exactly ONE approved safe change affecting the proven member envelope. No automatic command is issued."); + evidence.Add($"{readyMarker}: waiting up to {ReportWindow.TotalSeconds:0}s; GI=false; independentPoll=true; autoControl=false"); + + ArMms.MmsPersistentReportMonitorSliceResult receive; + try + { + receive = await reportSession.ReceivePersistentReportMonitorSliceAsync( + monitor, + ReportWindow, + pollDirectory: null, + pollReferences: null, + pollInterval: null, + triggerGeneralInterrogation: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + finally + { + pollCts.Cancel(); + try { await pollTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + + evidence.Add($"{label} receive: reports={receive.Reports.Count}; unrouted={reportSession.UnroutedPersistentReportCount}; route={TextOrDash(reportSession.LastReceiveRoutingSummary)}; GI=false; result={receive.Message}"); + foreach (var frame in receive.Reports) + { + var validation = DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame( + frame, + postLease!.ReportId, + plan.DataSetReference, + qualifiedReferences); + evidence.Add($"{label} report candidate: receivedAt={frame.ReceivedAt:O}; sqNum={frame.Header.SequenceNumber?.ToString() ?? "-"}; values={frame.Values.Count}; included=[{string.Join(",", frame.IncludedDataSetIndexes)}]; valid={validation.IsSuccess}; reason={validation.Reason}"); + if (!validation.IsSuccess) + continue; + + RecordFrame(frame, qualifiedReferences, recorder, evidence, label); + reportProven = true; + break; + } + + if (!reportProven) + evidence.Add($"{label} did not receive one exact dchg-only InformationReport inside the bounded window."); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"{label} fail-closed exception: {ex.GetType().Name}: {ex.Message}"); + } + finally + { + if (monitor is not null) + { + try + { + var stop = await reportSession.StopPersistentReportMonitorAsync(monitor, CancellationToken.None).ConfigureAwait(false); + monitorCleanup = stop.IsSuccess; + AppendWriteSteps(evidence, label + " monitor cleanup", stop.WriteSteps); + evidence.Add($"{label} monitor cleanup: success={stop.IsSuccess}; result={stop.Message}"); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException) + { + monitorCleanup = false; + evidence.Add($"{label} monitor cleanup exception: {ex.GetType().Name}: {ex.Message}"); + } + } + + if (fieldLease is not null) + { + fieldRestore = false; + try + { + var restore = await reportSession.RestoreDynamicRcbCommissioningFieldsAsync(fieldLease, CancellationToken.None).ConfigureAwait(false); + fieldRestore = restore.IsSuccess; + AppendWriteSteps(evidence, label + " proof-field restore", restore.WriteSteps); + evidence.Add($"{label} proof-field restore: success={restore.IsSuccess}; result={restore.Message}"); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException) + { + evidence.Add($"{label} proof-field restore exception: {ex.GetType().Name}: {ex.Message}"); + } + } + + await reportSession.DisposeAsync().ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(temporaryDataSetReference)) + { + freshClosure = await ProveFreshCleanupClosureAsync( + device, + rcbReference, + temporaryDataSetReference, + evidence, + label, + CancellationToken.None).ConfigureAwait(false); + } + + var cleanup = monitorCleanup && fieldRestore && freshClosure; + var success = activationProven && reportProven && pollReferenceRecovered && cleanup; + evidence.Add($"{label} combined: activation={activationProven}; report={reportProven}; pollReference={pollReferenceRecovered}; monitorCleanup={monitorCleanup}; fieldRestore={fieldRestore}; freshClosure={freshClosure}; success={success}"); + return new ShadowPhaseResult(success, cleanup, activationProven, pollReferenceRecovered); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"{label} polling association exception: {ex.GetType().Name}: {ex.Message}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + } + + private static async Task CapturePollCycleAsync( + ArMms.MmsClientSession session, + IReadOnlyList points, + IReadOnlyList qualifiedReferences, + DynamicReportShadowEvidenceRecorder recorder, + ICollection evidence, + string label, + CancellationToken cancellationToken) + { + if (points.Count != qualifiedReferences.Count) + return false; + + for (var index = 0; index < points.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await session.ReadSingleVariableAsync(points[index].ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null || !session.IsMmsInitiated) + { + evidence.Add($"{label}: read failed index={index}; ref={qualifiedReferences[index]}; result={read.Message}"); + return false; + } + + // Deliberately record only metadata physically returned by this exact value read. + // Separate q/t companion reads are not merged into this process observation in P2; + // otherwise absence on the report side could be accidentally hidden. + recorder.RecordPoll( + index, + qualifiedReferences[index], + ArMms.MmsDataValueRenderer.ToCompactString(read.Value), + quality: null, + deviceTimestampUtc: null, + readAtUtc: DateTimeOffset.UtcNow); + } + + return true; + } + + private static async Task PollLoopAsync( + ArMms.MmsClientSession session, + IReadOnlyList points, + IReadOnlyList qualifiedReferences, + DynamicReportShadowEvidenceRecorder recorder, + ICollection evidence, + string label, + CancellationToken cancellationToken) + { + var cycles = 0; + var failures = 0; + while (!cancellationToken.IsCancellationRequested && session.IsMmsInitiated) + { + cycles++; + for (var index = 0; index < points.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await session.ReadSingleVariableAsync(points[index].ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null) + { + failures++; + continue; + } + + recorder.RecordPoll( + index, + qualifiedReferences[index], + ArMms.MmsDataValueRenderer.ToCompactString(read.Value), + quality: null, + deviceTimestampUtc: null, + readAtUtc: DateTimeOffset.UtcNow); + } + + await Task.Delay(PollInterval, cancellationToken).ConfigureAwait(false); + } + + evidence.Add($"{label} independent polling stopped: cycles={cycles}; failures={failures}; associationHealthy={session.IsMmsInitiated}"); + } + + private static void RecordFrame( + ArMms.MmsReportFrame frame, + IReadOnlyList qualifiedReferences, + DynamicReportShadowEvidenceRecorder recorder, + ICollection evidence, + string label) + { + var projection = ArMms.MmsReportValueProjector.Project(frame); + foreach (var value in frame.Values) + { + if (value.Index < 0 || value.Index >= qualifiedReferences.Count || value.Value is null || value.FailureCode.HasValue) + continue; + + var expected = qualifiedReferences[value.Index]; + var projected = projection.Updates.FirstOrDefault(update => SameReference(update.Reference, expected) || + (value.Member is not null && SameReference(update.Reference, value.Member.UserReference))); + + var quality = projected?.HasQuality == true ? projected.Quality : null; + DateTimeOffset? deviceTimestamp = null; + if (projected?.HasTimestamp == true && + DateTimeOffset.TryParse(projected.Timestamp, out var parsedTimestamp)) + { + deviceTimestamp = parsedTimestamp; + } + + recorder.RecordReport( + value.Index, + expected, + ArMms.MmsDataValueRenderer.ToCompactString(value.Value), + quality, + deviceTimestamp, + frame.ReceivedAt, + frame.Header.SequenceNumber); + evidence.Add($"{label} recorded report observation: index={value.Index}; member={expected}; q={(string.IsNullOrWhiteSpace(quality) ? "missing" : "observed")}; t={(deviceTimestamp.HasValue ? "observed" : "missing")}; receivedAt={frame.ReceivedAt:O}; sqNum={frame.Header.SequenceNumber?.ToString() ?? "-"}"); + } + + foreach (var warning in projection.Warnings) + evidence.Add($"{label} report projection warning: {warning}"); + } + + private static async Task ProveFreshCleanupClosureAsync( + Iec61850MonitorDevice device, + string rcbReference, + string temporaryDataSetReference, + ICollection evidence, + string label, + CancellationToken cancellationToken) + { + await using var fresh = new ArMms.MmsClientSession(); + try + { + await fresh.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false); + var discovery = await fresh.DiscoverAsync( + probeReportAttributes: true, + maxReportAttributeProbes: 64, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + var rcb = discovery.ReportInventory.ReportControls.FirstOrDefault(candidate => SameReference(candidate.Reference, rcbReference)); + if (rcb is null) + { + evidence.Add($"{label} fresh cleanup: exact RCB absent."); + return false; + } + + var one = new ArMms.MmsReportInventory(); + one.ReportControls.Add(rcb); + var availability = await fresh.CheckReportControlAvailabilityAsync( + one, + discovery.IedDirectory, + new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, + cancellationToken).ConfigureAwait(false); + var snapshot = availability.ReportControls.SingleOrDefault(); + var nameAbsent = DynamicReportCleanupClosureCommissioningService.IsTemporaryDataSetAbsentFromNameList( + discovery.Snapshot, + temporaryDataSetReference, + out var nameReason); + var directory = await fresh.GetDataSetDirectoryAsync(temporaryDataSetReference, discovery.IedDirectory, cancellationToken).ConfigureAwait(false); + var directoryAbsent = !directory.IsSuccess; + var closed = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed( + snapshot, + nameAbsent, + directoryAbsent, + fresh.IsMmsInitiated, + out var closureReason); + evidence.Add($"{label} fresh cleanup: nameAbsent={nameAbsent}; directoryAbsent={directoryAbsent}; association={fresh.IsMmsInitiated}; namespace={nameReason}; result={closureReason}"); + return closed; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"{label} fresh cleanup exception: {ex.GetType().Name}: {ex.Message}"); + return false; + } + } + + private static void ApplyFreshSnapshot(ArMms.MmsReportControlCandidate target, ArMms.MmsRcbAvailabilitySnapshot source) + { + target.DataSetReference = source.DataSetReference; + target.DataSetProbeState = source.DataSetProbeState; + target.DataSetProbeMessage = source.DataSetProbeMessage; + target.ReportId = source.ReportId; + target.ConfRev = source.ConfRev; + target.BufferTimeMs = source.BufferTimeMs; + target.IntegrityPeriodMs = source.IntegrityPeriodMs; + target.TriggerOptions = source.TriggerOptions; + target.OptionalFields = source.OptionalFields; + target.EnabledState = source.EnabledState; + target.ReservationState = source.ReservationState; + target.ReservationTimeSeconds = source.ReservationTimeSeconds; + target.Owner = source.Owner; + target.Attributes = source.Attributes.ToList(); + } + + private static void EnsureAttribute(ArMms.MmsReportControlCandidate target, string attribute) + { + if (!target.Attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase)) + target.Attributes.Add(attribute); + } + + private static bool SuccessfulStep(IEnumerable steps, string attribute) + => steps.Any(step => step.Attempted && step.IsSuccess && step.Attribute.Equals(attribute, StringComparison.OrdinalIgnoreCase)); + + private static void AppendWriteSteps(ICollection evidence, string label, IEnumerable steps) + { + foreach (var step in steps) + evidence.Add($"{label} write: attribute={step.Attribute}; reference={step.Reference}; attempted={step.Attempted}; success={step.IsSuccess}; result={step.Message}"); + } + + private static bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual) + { + var left = expected.Select(NormalizeReference).ToArray(); + var right = actual.Select(NormalizeReference).ToArray(); + return left.Length == right.Length && left.SequenceEqual(right, StringComparer.OrdinalIgnoreCase); + } + + private static bool SameReference(string? left, string? right) + => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static bool? ParseBool(string? text) + { + if (bool.TryParse(text, out var parsed)) return parsed; + return (text ?? string.Empty).Trim() switch { "1" => true, "0" => false, _ => null }; + } + + private static string TextOrDash(string? text) + => string.IsNullOrWhiteSpace(text) ? "-" : text.Trim(); + + private static DynamicReportShadowVerificationCommissioningResult Blocked( + string summary, + IReadOnlyList evidence, + string? rcbReference = null, + IReadOnlyList? members = null) + => new() + { + IsBlocked = true, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + RcbReference = rcbReference ?? string.Empty, + MemberReferences = members?.ToArray() ?? Array.Empty(), + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportShadowVerificationCommissioningResult Failed( + string summary, + IReadOnlyList evidence, + string rcbReference, + IReadOnlyList members, + bool cleanupSucceeded) + => new() + { + Summary = summary, + RcbReference = rcbReference, + MemberReferences = members.ToArray(), + CleanupSucceeded = cleanupSucceeded, + EvidenceLines = evidence.ToArray() + }; + + private sealed record ShadowPhaseResult( + bool IsSuccess, + bool CleanupSucceeded, + bool ActivationProven, + bool PollReferenceRecovered) + { + public static ShadowPhaseResult Fail(bool cleanupSucceeded) + => new(false, cleanupSucceeded, false, false); + } +} From 8b217980a9efd4b96933d9c22f61583da5d9a934 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:12:11 +0700 Subject: [PATCH 58/77] G2.6: regress physical shadow collector safety and reconnect contract --- ...6ShadowPhysicalCollectorRegressionTests.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs diff --git a/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs new file mode 100644 index 00000000..b9e48b73 --- /dev/null +++ b/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs @@ -0,0 +1,104 @@ +namespace ARSAS.Tests; + +public sealed class G26ShadowPhysicalCollectorRegressionTests +{ + [Fact] + public void Collector_UsesIndependentReadOnlyPollingAndExactDchgReportAssociation() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("new ArMms.MmsClientSession()", source, StringComparison.Ordinal); + Assert.Contains("probeReportAttributes: false", source, StringComparison.Ordinal); + Assert.Contains("maxReportAttributeProbes: 0", source, StringComparison.Ordinal); + Assert.Contains("ReadSingleVariableAsync", source, StringComparison.Ordinal); + Assert.Contains("PrepareDynamicRcbCommissioningFieldsAsync", source, StringComparison.Ordinal); + Assert.Contains("TemporaryTriggerOptions = \"dchg\"", source, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: false", source, StringComparison.Ordinal); + Assert.Contains("ValidateSpontaneousDataChangeFrame", source, StringComparison.Ordinal); + } + + [Fact] + public void Collector_RequiresTwoPhasesAndOneDeliberateReconnectWithBothPathsRecovered() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("RunPhaseAsync(\n 1", source, StringComparison.Ordinal); + Assert.Contains("recorder.RecordReconnectAttempt()", source, StringComparison.Ordinal); + Assert.Contains("RunPhaseAsync(\n 2", source, StringComparison.Ordinal); + Assert.Contains("recorder.RecordReconnectSuccess", source, StringComparison.Ordinal); + Assert.Contains("reportResubscribed: phase2.ActivationProven", source, StringComparison.Ordinal); + Assert.Contains("pollReferenceRecovered: phase2.PollReferenceRecovered", source, StringComparison.Ordinal); + Assert.Contains("ReconnectAttempts == 1", source, StringComparison.Ordinal); + Assert.Contains("SuccessfulReconnects == 1", source, StringComparison.Ordinal); + } + + [Fact] + public void Collector_DoesNotSynthesizeQualityTimestampOrUseHeaderTimeAsDeviceTimestamp() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("missing report-side quality/timestamp evidence is never inferred", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("MmsReportValueProjector.Project(frame)", source, StringComparison.Ordinal); + Assert.Contains("projected?.HasQuality == true", source, StringComparison.Ordinal); + Assert.Contains("projected?.HasTimestamp == true", source, StringComparison.Ordinal); + Assert.Contains("quality: null", source, StringComparison.Ordinal); + Assert.Contains("deviceTimestampUtc: null", source, StringComparison.Ordinal); + Assert.DoesNotContain("frame.Header.TimeOfEntry", source, StringComparison.Ordinal); + } + + [Fact] + public void Collector_PerformsMandatoryMonitorProofFieldAndFreshAssociationCleanup() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("StopPersistentReportMonitorAsync", source, StringComparison.Ordinal); + Assert.Contains("RestoreDynamicRcbCommissioningFieldsAsync", source, StringComparison.Ordinal); + Assert.Contains("IsTemporaryDataSetAbsentFromNameList", source, StringComparison.Ordinal); + Assert.Contains("IsFreshCleanupClosed", source, StringComparison.Ordinal); + Assert.Contains("directoryAbsent = !directory.IsSuccess", source, StringComparison.Ordinal); + Assert.Contains("monitorCleanup && fieldRestore && freshClosure", source, StringComparison.Ordinal); + } + + [Fact] + public void Collector_NeverIssuesControlOrPromotesProfile() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.DoesNotContain("ExecuteControlAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("WriteControl", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("_profileStore.SaveAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("ZERO control commands", source, StringComparison.Ordinal); + Assert.Contains("Shadow PASS != ProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("production automatic dynamic reporting remains OFF", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Collector_FeedsStrictAcceptanceButLeavesIndependentRegressionsExplicit() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("bool controlRegressionPassed = false", source, StringComparison.Ordinal); + Assert.Contains("bool staticReportingRegressionPassed = false", source, StringComparison.Ordinal); + Assert.Contains("_acceptanceService.EvaluateAsync", source, StringComparison.Ordinal); + Assert.Contains("controlRegressionPassed,", source, StringComparison.Ordinal); + Assert.Contains("staticReportingRegressionPassed,", source, StringComparison.Ordinal); + Assert.Contains("strictProductionCandidate", source, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} From ccbbcbb339737b1035651b1b77016f6acf54d939 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:12:37 +0700 Subject: [PATCH 59/77] G2.6: add physical shadow evidence result view --- ...portQualificationResultWindow.G26Shadow.cs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 DynamicReportQualificationResultWindow.G26Shadow.cs diff --git a/DynamicReportQualificationResultWindow.G26Shadow.cs b/DynamicReportQualificationResultWindow.G26Shadow.cs new file mode 100644 index 00000000..be1522d6 --- /dev/null +++ b/DynamicReportQualificationResultWindow.G26Shadow.cs @@ -0,0 +1,115 @@ +using System.Text; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +internal partial class DynamicReportQualificationResultWindow +{ + internal DynamicReportQualificationResultWindow(DynamicReportShadowVerificationCommissioningResult result) + { + ArgumentNullException.ThrowIfNull(result); + InitializeComponent(); + + Title = "G2.6 Physical Shadow Verification Evidence"; + HeaderText.Text = "G2.6 Report vs Independent MMS Shadow"; + SummaryText.Text = result.Summary; + StateText.Text = result.IsBlocked + ? "Blocked" + : result.ShadowPassed + ? "Shadow Passed / Production OFF" + : result.PhysicalCollectionCompleted + ? "Collected / Shadow Not Passed" + : "Incomplete"; + EvidenceTextBox.Text = BuildG26ShadowEvidence(result); + + if (result.ShadowPassed) + SetPassBadge(); + } + + private static string BuildG26ShadowEvidence(DynamicReportShadowVerificationCommissioningResult result) + { + var builder = new StringBuilder(); + builder.AppendLine("ARSAS G2.6 PHYSICAL SHADOW VERIFICATION EVIDENCE"); + builder.AppendLine(new string('=', 68)); + builder.AppendLine($"Result: {result.Summary}"); + builder.AppendLine($"Blocked: {result.IsBlocked}"); + builder.AppendLine($"Physical collection completed: {result.PhysicalCollectionCompleted}"); + builder.AppendLine($"Typed shadow passed: {result.ShadowPassed}"); + builder.AppendLine($"Cleanup succeeded: {result.CleanupSucceeded}"); + builder.AppendLine($"Deliberate reconnect proven: {result.ReconnectProven}"); + builder.AppendLine($"Exact RCB: {Dash(result.RcbReference)}"); + builder.AppendLine($"Exact member count: {result.MemberReferences.Count}"); + + if (result.MemberReferences.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("EXACT INFORMATIONREPORT-PROVEN MEMBER ENVELOPE"); + for (var index = 0; index < result.MemberReferences.Count; index++) + builder.AppendLine($"[{index}] {result.MemberReferences[index]}"); + } + + if (result.Evidence is not null) + { + var evidence = result.Evidence; + builder.AppendLine(); + builder.AppendLine("PHYSICAL SHADOW COUNTERS"); + builder.AppendLine($"Evidence ID: {evidence.EvidenceId}"); + builder.AppendLine($"Report observations: {evidence.ReportObservations.Count}"); + builder.AppendLine($"Independent MMS polls: {evidence.PollObservations.Count}"); + builder.AppendLine($"Reconnects: {evidence.SuccessfulReconnects}/{evidence.ReconnectAttempts}"); + builder.AppendLine($"Report resubscriptions after reconnect: {evidence.ReportResubscriptionsAfterReconnect}"); + builder.AppendLine($"Poll reference recoveries after reconnect: {evidence.PollReferenceRecoveriesAfterReconnect}"); + builder.AppendLine($"Dynamic activation attempts: {evidence.DynamicActivationAttempts}"); + builder.AppendLine($"Report quality observations: {evidence.ReportObservations.Count(item => !string.IsNullOrWhiteSpace(item.Quality))}"); + builder.AppendLine($"Report device timestamp observations: {evidence.ReportObservations.Count(item => item.DeviceTimestampUtc.HasValue)}"); + } + + if (result.Acceptance?.Shadow is not null) + { + var shadow = result.Acceptance.Shadow; + builder.AppendLine(); + builder.AppendLine("TYPED ARIEC SHADOW GATES"); + builder.AppendLine($"Exact member identity: {shadow.ExactMemberIdentityPassed}"); + builder.AppendLine($"Value parity: {shadow.ValueParityPassed}"); + builder.AppendLine($"Quality parity: {shadow.QualityParityPassed}"); + builder.AppendLine($"Device timestamp parity: {shadow.TimestampParityPassed}"); + builder.AppendLine($"Report order: {shadow.ReportOrderPassed}"); + builder.AppendLine($"No missing report edges: {shadow.NoMissingReportEdgesPassed}"); + builder.AppendLine($"No duplicate report edges: {shadow.NoDuplicateReportEdgesPassed}"); + builder.AppendLine($"Polling authority: {shadow.PollingAuthorityGuardPassed}"); + builder.AppendLine($"Reconnect regression: {shadow.ReconnectRegressionPassed}"); + builder.AppendLine($"No repeated mutation loop: {shadow.NoRepeatedMutationLoopPassed}"); + foreach (var failure in shadow.Failures) + builder.AppendLine("FAIL: " + failure); + } + + if (result.Acceptance?.ProductionAcceptanceCandidate is not null) + { + var candidate = result.Acceptance.ProductionAcceptanceCandidate; + builder.AppendLine(); + builder.AppendLine("STRICT PRODUCTION-ACCEPTANCE CANDIDATE — NOT PERSISTED"); + builder.AppendLine($"Smart Control regression: {candidate.ControlRegressionPassed}"); + builder.AppendLine($"Static reporting regression: {candidate.StaticReportingRegressionPassed}"); + builder.AppendLine($"Dynamic InformationReport regression: {candidate.DynamicInformationReportRegressionPassed}"); + builder.AppendLine($"Polling authority: {candidate.PollingAuthorityGuardPassed}"); + builder.AppendLine($"Reconnect regression: {candidate.ReconnectRegressionPassed}"); + builder.AppendLine($"Observed q/t regression: {candidate.QualityRegressionPassed}"); + builder.AppendLine($"No mutation loop: {candidate.NoRepeatedMutationLoopPassed}"); + builder.AppendLine($"All passed: {candidate.AllPassed}"); + } + + builder.AppendLine(); + builder.AppendLine("DETAILED EVIDENCE"); + foreach (var line in result.EvidenceLines) + builder.AppendLine(line); + + builder.AppendLine(); + builder.AppendLine("STATE BOUNDARY"); + builder.AppendLine("Shadow PASS != ProductionEligible."); + builder.AppendLine("The persisted profile remains InformationReportProven and production automatic dynamic reporting remains OFF."); + return builder.ToString(); + } + + private static string Dash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); +} From db25d2fb79567ec33b7a4ad890cbe583991159f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:13:15 +0700 Subject: [PATCH 60/77] G2.6: wire Ctrl+Shift+S physical shadow commissioning flow --- DynamicReportCommandBoundWitnessUiBehavior.cs | 76 +++++++++++++++---- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs index 67075e21..b7e6f2d4 100644 --- a/DynamicReportCommandBoundWitnessUiBehavior.cs +++ b/DynamicReportCommandBoundWitnessUiBehavior.cs @@ -57,20 +57,27 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) { if (sender is not MainWindow window || Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) || - (e.Key != Key.F && e.Key != Key.A)) + (e.Key != Key.F && e.Key != Key.A && e.Key != Key.S)) return; e.Handled = true; var device = window.SelectedDevice; var a3 = e.Key == Key.A; - var title = a3 ? "G2.6-P1 Q0 Target-Locked Auto A3" : "G2.5-A2.1 Command-Bound Witness"; + var shadow = e.Key == Key.S; + var title = shadow + ? "G2.6 Physical Shadow Verification" + : a3 + ? "G2.6-P1 Q0 Target-Locked Auto A3" + : "G2.5-A2.1 Command-Bound Witness"; if (device is null) { MessageBox.Show( window, - a3 - ? "Select the qualified AA1C1F08R4 IEC 61850 IED first. Q0 Auto A3 is hard-bound to the exact proven field identity and AA1C1F08R4Q0/CSWI1.Pos." - : "Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.", + shadow + ? "Select the exact identity-compatible InformationReportProven IEC 61850 IED first. G2.6 shadow is bound to the persisted proven RCB/member envelope and will not guess another target." + : a3 + ? "Select the qualified AA1C1F08R4 IEC 61850 IED first. Q0 Auto A3 is hard-bound to the exact proven field identity and AA1C1F08R4Q0/CSWI1.Pos." + : "Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.", title, MessageBoxButton.OK, MessageBoxImage.Information); @@ -81,7 +88,7 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) { MessageBox.Show( window, - "A command-bound G2 commissioning witness is already armed/running.", + "A G2 commissioning witness/shadow action is already armed or running.", title, MessageBoxButton.OK, MessageBoxImage.Information); @@ -90,21 +97,27 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) try { - if (a3) + if (shadow) + await RunPhysicalShadowAsync(window, device); + else if (a3) await RunDeterministicA3Async(window, device); else await RunA21Async(window, device); } catch (Exception ex) { - window.LastStatusText = a3 - ? "G2.6-P1 Q0 Auto A3 stopped fail-closed. No retry/CLOSE/toggle is issued; ProductionEligible remains OFF." - : "G2.5-A2.1 V3 stopped locally; production dynamic reporting remains OFF."; + window.LastStatusText = shadow + ? "G2.6 physical shadow stopped fail-closed. Cleanup evidence is retained; profile remains InformationReportProven and ProductionEligible remains OFF." + : a3 + ? "G2.6-P1 Q0 Auto A3 stopped fail-closed. No retry/CLOSE/toggle is issued; ProductionEligible remains OFF." + : "G2.5-A2.1 V3 stopped locally; production dynamic reporting remains OFF."; MessageBox.Show( window, - (a3 - ? "G2.6-P1 Q0 target-locked Auto A3 stopped. Ctrl+Shift+A is the explicit commissioning action, but the one-shot OPEN is dispatched only after exact identity, Q0 status, Closed-state, command-focus, dchg-arm and final-baseline gates close. A blocked/ambiguous command is never retried and no CLOSE/toggle/auto-restore is issued. Recovery remains transactional and A3 cleanup remains mandatory. ProductionEligible stays OFF.\n\n" - : "G2.5-A2.1 V3 stopped. The witness did not change production reporting policy.\n\n") + ex, + (shadow + ? "G2.6 physical shadow stopped. The collector issues zero automatic control commands and cannot promote the profile. Inspect cleanup/reconnect evidence before retry. Production automatic dynamic reporting remains OFF.\n\n" + : a3 + ? "G2.6-P1 Q0 target-locked Auto A3 stopped. Ctrl+Shift+A is the explicit commissioning action, but the one-shot OPEN is dispatched only after exact identity, Q0 status, Closed-state, command-focus, dchg-arm and final-baseline gates close. A blocked/ambiguous command is never retried and no CLOSE/toggle/auto-restore is issued. Recovery remains transactional and A3 cleanup remains mandatory. ProductionEligible stays OFF.\n\n" + : "G2.5-A2.1 V3 stopped. The witness did not change production reporting policy.\n\n") + ex, title, MessageBoxButton.OK, MessageBoxImage.Error); @@ -171,4 +184,39 @@ private static async Task RunDeterministicA3Async(MainWindow window, Models.Iec6 var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } -} \ No newline at end of file + + private static async Task RunPhysicalShadowAsync(MainWindow window, Models.Iec61850MonitorDevice device) + { + var answer = MessageBox.Show( + window, + $"Start G2.6 physical shadow verification for {device.Name} ({device.EndpointText})?\n\n" + + "TWO PHYSICAL PHASES + ONE DELIBERATE RECONNECT\n\n" + + "The collector will use the exact persisted InformationReportProven URCB/member envelope. Each phase opens an independent READ-ONLY MMS polling association plus one transactional dchg-only report association.\n\n" + + "When the status shows 'G2.6 SHADOW PHASE 1 READY — CAUSE ONE SAFE CHANGE', cause exactly ONE already-approved safe process/status change affecting the proven envelope. After cleanup, the collector deliberately reconnects both paths and will show a second READY marker for one more safe change.\n\n" + + "Ctrl+Shift+S issues ZERO automatic control commands. It sends no GI, never edits the persisted qualification profile, never marks ProductionEligible, and retains mandatory monitor/proof-field/fresh-association cleanup.\n\n" + + "Quality/timestamp evidence is never invented. If the scalar report envelope does not physically carry paired q/t, the strict PR #99 shadow gate will remain BLOCKED/FAIL and that field finding is intentional.\n\n" + + "Independent Smart Control/static-report regressions are NOT assumed by this action, so even a shadow PASS is not an automatic production promotion.\n\n" + + "Continue?", + "G2.6 Physical Shadow Verification", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + + window.LastStatusText = $"G2.6 physical shadow starting for {device.Name}: validating exact profile, opening read-only polling reference and transactional dchg report path…"; + var progress = new Progress(text => window.LastStatusText = text); + var service = new DynamicReportShadowVerificationCommissioningService(); + var result = await service.RunAsync( + device, + device.Signals.ToArray(), + progress, + controlRegressionPassed: false, + staticReportingRegressionPassed: false, + CancellationToken.None); + + window.LastStatusText = result.Summary; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; + evidenceWindow.ShowDialog(); + } +} From e7b6018d54b56d91466cddae85b31c0701152c46 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:14:22 +0700 Subject: [PATCH 61/77] docs: define G2.6 physical shadow commissioning gate --- docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md diff --git a/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md b/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md new file mode 100644 index 00000000..0341f686 --- /dev/null +++ b/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md @@ -0,0 +1,110 @@ +# G2.6 Physical Shadow Verification + +## Purpose + +This phase sits after the deterministic command-bound A3 proof and before any later `ProductionEligible` decision. + +It answers one question only: + +> Can the exact `InformationReportProven` dynamic report path remain trustworthy when compared continuously against an independent read-only MMS reference, including across a deliberate reconnect? + +A shadow PASS is **not** a production promotion. + +## Operator entry point + +Select the qualified IED and press: + +`Ctrl + Shift + S` + +The action is explicit commissioning only. It is not executed automatically by normal monitoring. + +## Physical topology + +Each phase uses two independent MMS associations: + +1. **Report association** + - exact persisted URCB only; + - exact persisted G2.4 member sequence only; + - transactional `TrgOps=dchg` lease; + - `OptFlds=reason-for-inclusion data-set-name`; + - GI/integrity/qchg/dupd remain disabled; + - dynamic DataSet is temporary and cleaned on stop. +2. **Reference association** + - read-only direct MMS reads; + - exact same proven member sequence; + - no RCB/DataSet access or mutation; + - bounded 250 ms polling while the report phase is armed. + +## Two-phase reconnect contract + +The collector performs: + +1. phase 1 report + polling proof; +2. complete monitor/proof-field/fresh-association cleanup; +3. deliberate teardown/reconnect; +4. phase 2 report re-subscription + independent polling-reference recovery; +5. complete cleanup again; +6. typed ARIEC shadow evaluation. + +The READY markers are: + +- `G2.6 SHADOW PHASE 1 READY — CAUSE ONE SAFE CHANGE` +- `G2.6 SHADOW PHASE 2 READY — CAUSE ONE SAFE CHANGE` + +After each marker, cause exactly one already-approved safe process/status change affecting the proven envelope. The shadow collector itself issues **zero** control commands. + +## Evidence rules + +Every report observation is bound to the exact DataSet index/member pair already persisted in `RcbActivationProof.MemberReferences`. + +Every polling observation is independently read through the second MMS association and is recorded against that same exact index/member pair. + +The collector records: + +- report values and receive ordering; +- independent polling values; +- exact report sequence number when supplied; +- report-carried quality/timestamp only when ARIEC physically projects those fields from the received InformationReport; +- reconnect attempts and successes; +- report re-subscription after reconnect; +- polling-reference recovery after reconnect; +- bounded dynamic activation-attempt count; +- monitor cleanup, proof-field restore and fresh-association closure. + +## Quality / timestamp boundary + +The currently proven field envelope may contain scalar primary members such as `CSWI1.Pos.stVal`. + +A scalar report member does not automatically prove that its data-object quality and timestamp were transported in the same InformationReport. Therefore this phase deliberately does **not**: + +- copy polling quality into a report observation; +- copy polling timestamps into a report observation; +- treat report receive time as the IEC data-object timestamp; +- treat report header `TimeOfEntry` as the member's device timestamp; +- invent missing q/t from companion reads. + +ARSAS now pins ARIEC61850 PR #99 (`1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f`). The strict production-facing acceptance policy requires actually observed paired report/poll quality evidence and actually observed paired report/poll device timestamp evidence. If the physical report envelope does not carry them, the gate remains fail-closed. That result is useful field evidence, not a software failure to be bypassed. + +## Acceptance layers + +The collector separates three outcomes: + +1. **Physical collection completed** — both phases, cleanup and reconnect finished. +2. **Typed shadow passed** — ARIEC exact identity/value/q/t/order/missing/duplicate/reconnect/mutation-loop checks all passed. +3. **Production-acceptance candidate** — additionally includes independent Smart Control and static-reporting regression inputs. + +`Ctrl + Shift + S` passes those independent control/static inputs as `false`; it never assumes unrelated regressions passed. A later explicit gate must supply reviewed evidence if those regressions are to become true. + +## State invariant + +This phase never calls `DynamicReportQualificationProfileStore.SaveAsync` and never calls `MarkProductionEligible`. + +The persisted profile remains: + +`InformationReportProven` + +and production automatic dynamic reporting remains: + +`OFF` + +until a later, separately reviewed promotion gate is implemented and physically justified. From 79348adda5f9c1639d56a5043064925e3df61148 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:14:38 +0700 Subject: [PATCH 62/77] G2.6: regress explicit shadow hotkey and production-off UX --- .../ARSAS.Tests/G26ShadowUiRegressionTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/ARSAS.Tests/G26ShadowUiRegressionTests.cs diff --git a/tests/ARSAS.Tests/G26ShadowUiRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowUiRegressionTests.cs new file mode 100644 index 00000000..54ce0672 --- /dev/null +++ b/tests/ARSAS.Tests/G26ShadowUiRegressionTests.cs @@ -0,0 +1,49 @@ +namespace ARSAS.Tests; + +public sealed class G26ShadowUiRegressionTests +{ + [Fact] + public void Shadow_HasDedicatedCtrlShiftSActionSeparateFromA21AndA3() + { + var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); + + Assert.Contains("e.Key != Key.F && e.Key != Key.A && e.Key != Key.S", ui, StringComparison.Ordinal); + Assert.Contains("var shadow = e.Key == Key.S", ui, StringComparison.Ordinal); + Assert.Contains("RunPhysicalShadowAsync", ui, StringComparison.Ordinal); + Assert.Contains("DynamicReportShadowVerificationCommissioningService", ui, StringComparison.Ordinal); + Assert.Contains("Ctrl+Shift+S issues ZERO automatic control commands", ui, StringComparison.Ordinal); + Assert.Contains("controlRegressionPassed: false", ui, StringComparison.Ordinal); + Assert.Contains("staticReportingRegressionPassed: false", ui, StringComparison.Ordinal); + } + + [Fact] + public void Shadow_UiRequiresTwoExplicitReadyMarkersAndShowsEvidenceWindow() + { + var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); + var service = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + var window = Read("DynamicReportQualificationResultWindow.G26Shadow.cs"); + + Assert.Contains("G2.6 SHADOW PHASE 1 READY — CAUSE ONE SAFE CHANGE", ui, StringComparison.Ordinal); + Assert.Contains("Phase1ReadyMarker", service, StringComparison.Ordinal); + Assert.Contains("Phase2ReadyMarker", service, StringComparison.Ordinal); + Assert.Contains("new DynamicReportQualificationResultWindow(result)", ui, StringComparison.Ordinal); + Assert.Contains("Shadow PASS != ProductionEligible", window, StringComparison.Ordinal); + Assert.Contains("production automatic dynamic reporting remains OFF", window, StringComparison.OrdinalIgnoreCase); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} From 01da323d5b5cb1312bdcd35985b61ce879bc2893 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:18:04 +0700 Subject: [PATCH 63/77] G2.6: fix physical shadow collector build contracts --- .../DynamicReportShadowVerificationCommissioningService.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Services/DynamicReportShadowVerificationCommissioningService.cs b/Services/DynamicReportShadowVerificationCommissioningService.cs index beeb01c1..7894aeaf 100644 --- a/Services/DynamicReportShadowVerificationCommissioningService.cs +++ b/Services/DynamicReportShadowVerificationCommissioningService.cs @@ -314,8 +314,9 @@ private static async Task RunPhaseAsync( new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, cancellationToken).ConfigureAwait(false); var preLease = preLeaseAvailability.ReportControls.SingleOrDefault(); - var free = preLease is not null && DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(preLease, out var freeReason); - evidence.Add($"{label} pre-lease exact URCB: free={free}; reason={(preLease is null ? "snapshot missing" : freeReason)}"); + var freeReason = "snapshot missing"; + var free = preLease is not null && DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(preLease, out freeReason); + evidence.Add($"{label} pre-lease exact URCB: free={free}; reason={freeReason}"); if (!free || preLease is null) return ShadowPhaseResult.Fail(cleanupSucceeded: true); @@ -414,7 +415,7 @@ private static async Task RunPhaseAsync( progress?.Report($"{readyMarker} — report is strict dchg-only and independent MMS polling is already active. Cause exactly ONE approved safe change affecting the proven member envelope. No automatic command is issued."); evidence.Add($"{readyMarker}: waiting up to {ReportWindow.TotalSeconds:0}s; GI=false; independentPoll=true; autoControl=false"); - ArMms.MmsPersistentReportMonitorSliceResult receive; + ArMms.MmsPersistentReportMonitorReceiveResult receive; try { receive = await reportSession.ReceivePersistentReportMonitorSliceAsync( From 8f6dde06225929924c67d43053c6b7c4e628e0bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 19:23:00 +0700 Subject: [PATCH 64/77] G2.6: make no-promotion regression detect calls not documentation --- tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs index b9e48b73..e4b5ac6b 100644 --- a/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs +++ b/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs @@ -67,7 +67,8 @@ public void Collector_NeverIssuesControlOrPromotesProfile() Assert.DoesNotContain("ExecuteControlAsync", source, StringComparison.Ordinal); Assert.DoesNotContain("WriteControl", source, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("_profileStore.SaveAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + Assert.DoesNotContain("MmsDynamicReportQualificationProfilePolicy.MarkProductionEligible(", source, StringComparison.Ordinal); + Assert.Contains("never calls MarkProductionEligible", source, StringComparison.Ordinal); Assert.Contains("ZERO control commands", source, StringComparison.Ordinal); Assert.Contains("Shadow PASS != ProductionEligible", source, StringComparison.Ordinal); Assert.Contains("production automatic dynamic reporting remains OFF", source, StringComparison.OrdinalIgnoreCase); From d4876cb4099d0dd067716da155257771185803a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 10:38:40 +0700 Subject: [PATCH 65/77] G2.6: read independent polling quality and timestamp companions --- ...namicReportShadowPollingCompanionReader.cs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 Services/DynamicReportShadowPollingCompanionReader.cs diff --git a/Services/DynamicReportShadowPollingCompanionReader.cs b/Services/DynamicReportShadowPollingCompanionReader.cs new file mode 100644 index 00000000..1ed61b77 --- /dev/null +++ b/Services/DynamicReportShadowPollingCompanionReader.cs @@ -0,0 +1,186 @@ +using ArBinding = AR.Iec61850.Binding; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed record DynamicReportShadowPollCompanionEvidence( + string? Quality, + DateTimeOffset? DeviceTimestampUtc, + string QualityReference, + string TimestampReference, + bool QualityReadAttempted, + bool TimestampReadAttempted); + +/// +/// Bounded read-only companion collector for the G2.6 independent MMS polling authority. +/// It derives only known IEC 61850 data-object sibling q/t paths from an exact persisted +/// process member, resolves them against the already-discovered live MMS directory, and +/// performs at most one q read plus one t read. Missing, unreadable, or undecodable +/// companions remain missing; no receive time, report metadata, or other fallback is used. +/// +internal static class DynamicReportShadowPollingCompanionReader +{ + private static readonly string[] KnownValueSuffixes = + { + ".instCVal.mag.f", + ".cVal.mag.f", + ".instMag.f", + ".mag.f", + ".stVal", + ".general", + ".dirGeneral", + ".phsA", + ".dirPhsA", + ".phsB", + ".dirPhsB", + ".phsC", + ".dirPhsC" + }; + + internal static async Task ReadAsync( + ArMms.MmsClientSession session, + ArMms.MmsIedModelDirectory directory, + ArMms.MmsFcResolvedPoint point, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(point); + + if (!TryBuildCompanionReferences(point.MmsReference, out var qualityReference, out var timestampReference)) + return Missing(string.Empty, string.Empty, false, false); + + string? quality = null; + DateTimeOffset? deviceTimestampUtc = null; + var qualityAttempted = false; + var timestampAttempted = false; + + if (TryResolveExactCompanion(directory, qualityReference, point.FunctionalConstraint, out var qualityPoint)) + { + qualityAttempted = true; + try + { + var read = await session + .ReadSingleVariableAsync(qualityPoint.ToObjectReference(), cancellationToken) + .ConfigureAwait(false); + if (read.IsSuccess && read.Value is not null) + { + var decoded = ArBinding.Iec61850QualityDecoder.Decode(read.Value); + if (decoded.IsDecoded) + quality = decoded.Validity; + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + // Companion metadata is optional evidence. A failed q read remains missing; + // it must never be substituted from report-side or host-side metadata. + } + } + + if (TryResolveExactCompanion(directory, timestampReference, point.FunctionalConstraint, out var timestampPoint)) + { + timestampAttempted = true; + try + { + var read = await session + .ReadSingleVariableAsync(timestampPoint.ToObjectReference(), cancellationToken) + .ConfigureAwait(false); + if (read.IsSuccess && read.Value is not null) + { + var decoded = ArBinding.Iec61850TimestampDecoder.Decode(read.Value); + if (decoded.IsDecoded && TryFindUtcTime(read.Value, out var utcTime)) + deviceTimestampUtc = utcTime.Value.ToUniversalTime(); + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + // Same fail-closed rule as q: no observedAt/receive-time fallback. + } + } + + return new DynamicReportShadowPollCompanionEvidence( + quality, + deviceTimestampUtc, + qualityReference, + timestampReference, + qualityAttempted, + timestampAttempted); + } + + internal static bool TryBuildCompanionReferences( + string valueReference, + out string qualityReference, + out string timestampReference) + { + var normalized = (valueReference ?? string.Empty).Trim().Replace('$', '.'); + foreach (var suffix in KnownValueSuffixes) + { + if (!normalized.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + continue; + + var dataObjectReference = normalized[..^suffix.Length]; + if (string.IsNullOrWhiteSpace(dataObjectReference) || !dataObjectReference.Contains('/')) + break; + + qualityReference = dataObjectReference + ".q"; + timestampReference = dataObjectReference + ".t"; + return true; + } + + qualityReference = string.Empty; + timestampReference = string.Empty; + return false; + } + + private static bool TryResolveExactCompanion( + ArMms.MmsIedModelDirectory directory, + string reference, + string expectedFunctionalConstraint, + out ArMms.MmsFcResolvedPoint point) + { + point = null!; + if (string.IsNullOrWhiteSpace(reference) || !directory.TryFindByMmsReference(reference, out var resolved)) + return false; + + if (resolved.IsControlAttribute || resolved.IsReportAttribute || + !resolved.FunctionalConstraint.Equals(expectedFunctionalConstraint, StringComparison.OrdinalIgnoreCase) || + !NormalizeReference(resolved.MmsReference).Equals(NormalizeReference(reference), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + point = resolved; + return true; + } + + private static bool TryFindUtcTime(ArMms.MmsDataValue value, out ArMms.Iec61850UtcTime utcTime) + { + if (value.Kind == ArMms.MmsDataKind.UtcTime && value.Value is ArMms.Iec61850UtcTime direct) + { + utcTime = direct; + return true; + } + + if (value.Kind is ArMms.MmsDataKind.Structure or ArMms.MmsDataKind.Array) + { + foreach (var child in value.Children) + { + if (TryFindUtcTime(child, out utcTime)) + return true; + } + } + + utcTime = default; + return false; + } + + private static DynamicReportShadowPollCompanionEvidence Missing( + string qualityReference, + string timestampReference, + bool qualityAttempted, + bool timestampAttempted) + => new(null, null, qualityReference, timestampReference, qualityAttempted, timestampAttempted); + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); +} From e0ea0408f2261cf0d7b5e5d81ea9e201e399a476 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 10:39:51 +0700 Subject: [PATCH 66/77] G2.6: collect independent polling q/t evidence --- ...tShadowVerificationCommissioningService.cs | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/Services/DynamicReportShadowVerificationCommissioningService.cs b/Services/DynamicReportShadowVerificationCommissioningService.cs index 7894aeaf..42376e97 100644 --- a/Services/DynamicReportShadowVerificationCommissioningService.cs +++ b/Services/DynamicReportShadowVerificationCommissioningService.cs @@ -28,11 +28,11 @@ internal sealed class DynamicReportShadowVerificationCommissioningResult /// deliberate teardown/reconnect between them. It never issues a control command, never /// writes the qualification profile and never calls MarkProductionEligible. /// -/// Quality/timestamp evidence is accepted only when it is physically carried by the -/// received InformationReport and projected by ARIEC. This first collector deliberately -/// does NOT copy polling metadata into the report side or synthesize missing q/t. The -/// strict PR #99 acceptance policy therefore remains fail-closed if the currently proven -/// scalar DataSet envelope does not physically carry paired q/t evidence. +/// Report quality/timestamp evidence is accepted only when it is physically carried by the +/// received InformationReport and projected by ARIEC. Poll quality/timestamp evidence is +/// independently read from exact live q/t companion objects on the isolated read-only MMS +/// polling association. Neither side borrows metadata from the other, and host receive/read +/// time is never substituted for an IEC 61850 device timestamp. /// internal sealed class DynamicReportShadowVerificationCommissioningService { @@ -70,7 +70,7 @@ public async Task RunAsync( "G2.6 physical shadow contract: exact persisted InformationReportProven envelope + transactional one-URCB dchg reporting + independent read-only MMS polling + deliberate reconnect.", "G2.6 physical shadow command safety: this collector issues ZERO control commands. The operator must cause exactly one already-approved safe process/status change only after each READY marker.", "G2.6 physical shadow profile safety: no profile save, no downgrade, no promotion, no MarkProductionEligible. Production automatic dynamic reporting remains OFF.", - "G2.6 physical shadow q/t safety: missing report-side quality/timestamp evidence is never inferred from polling, report receive time, TimeOfEntry, or any companion read." + "G2.6 physical shadow q/t safety: report q/t is accepted only from the InformationReport; poll q/t is read independently from exact live q/t companions. Missing metadata stays missing; TimeOfEntry/read time is never a device-timestamp fallback." }; ArMms.MmsDynamicReportIedIdentity identity; @@ -168,6 +168,7 @@ public async Task RunAsync( var collected = recorder.BuildEvidence(DateTimeOffset.UtcNow); lines.Add($"G2.6 physical evidence collected: reports={collected.ReportObservations.Count}; polls={collected.PollObservations.Count}; reconnect={collected.SuccessfulReconnects}/{collected.ReconnectAttempts}; reportResubscriptions={collected.ReportResubscriptionsAfterReconnect}; pollRecoveries={collected.PollReferenceRecoveriesAfterReconnect}; dynamicAttempts={collected.DynamicActivationAttempts}"); lines.Add($"G2.6 observed report metadata: qualityObservations={collected.ReportObservations.Count(item => !string.IsNullOrWhiteSpace(item.Quality))}; timestampObservations={collected.ReportObservations.Count(item => item.DeviceTimestampUtc.HasValue)}. Missing q/t remains missing by design."); + lines.Add($"G2.6 observed independent poll metadata: qualityObservations={collected.PollObservations.Count(item => !string.IsNullOrWhiteSpace(item.Quality))}; timestampObservations={collected.PollObservations.Count(item => item.DeviceTimestampUtc.HasValue)}. Missing q/t remains missing by design."); var acceptance = await _acceptanceService.EvaluateAsync( device, @@ -253,6 +254,7 @@ private static async Task RunPhaseAsync( pollReferenceRecovered = await CapturePollCycleAsync( pollSession, + pollDiscovery.IedDirectory, pollPoints, qualifiedReferences, recorder, @@ -404,6 +406,7 @@ private static async Task RunPhaseAsync( using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var pollTask = PollLoopAsync( pollSession, + pollDiscovery.IedDirectory, pollPoints, qualifiedReferences, recorder, @@ -520,6 +523,7 @@ private static async Task RunPhaseAsync( private static async Task CapturePollCycleAsync( ArMms.MmsClientSession session, + ArMms.MmsIedModelDirectory directory, IReadOnlyList points, IReadOnlyList qualifiedReferences, DynamicReportShadowEvidenceRecorder recorder, @@ -533,6 +537,7 @@ private static async Task CapturePollCycleAsync( for (var index = 0; index < points.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); + var readAtUtc = DateTimeOffset.UtcNow; var read = await session.ReadSingleVariableAsync(points[index].ToObjectReference(), cancellationToken).ConfigureAwait(false); if (!read.IsSuccess || read.Value is null || !session.IsMmsInitiated) { @@ -540,16 +545,20 @@ private static async Task CapturePollCycleAsync( return false; } - // Deliberately record only metadata physically returned by this exact value read. - // Separate q/t companion reads are not merged into this process observation in P2; - // otherwise absence on the report side could be accidentally hidden. + var companion = await DynamicReportShadowPollingCompanionReader.ReadAsync( + session, + directory, + points[index], + cancellationToken).ConfigureAwait(false); + recorder.RecordPoll( index, qualifiedReferences[index], ArMms.MmsDataValueRenderer.ToCompactString(read.Value), - quality: null, - deviceTimestampUtc: null, - readAtUtc: DateTimeOffset.UtcNow); + companion.Quality, + companion.DeviceTimestampUtc, + readAtUtc); + evidence.Add($"{label}: read success index={index}; ref={qualifiedReferences[index]}; q={(string.IsNullOrWhiteSpace(companion.Quality) ? "missing" : "observed")}; t={(companion.DeviceTimestampUtc.HasValue ? "observed" : "missing")}; qAttempt={companion.QualityReadAttempted}; tAttempt={companion.TimestampReadAttempted}; qRef={TextOrDash(companion.QualityReference)}; tRef={TextOrDash(companion.TimestampReference)}"); } return true; @@ -557,6 +566,7 @@ private static async Task CapturePollCycleAsync( private static async Task PollLoopAsync( ArMms.MmsClientSession session, + ArMms.MmsIedModelDirectory directory, IReadOnlyList points, IReadOnlyList qualifiedReferences, DynamicReportShadowEvidenceRecorder recorder, @@ -572,6 +582,7 @@ private static async Task PollLoopAsync( for (var index = 0; index < points.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); + var readAtUtc = DateTimeOffset.UtcNow; var read = await session.ReadSingleVariableAsync(points[index].ToObjectReference(), cancellationToken).ConfigureAwait(false); if (!read.IsSuccess || read.Value is null) { @@ -579,13 +590,19 @@ private static async Task PollLoopAsync( continue; } + var companion = await DynamicReportShadowPollingCompanionReader.ReadAsync( + session, + directory, + points[index], + cancellationToken).ConfigureAwait(false); + recorder.RecordPoll( index, qualifiedReferences[index], ArMms.MmsDataValueRenderer.ToCompactString(read.Value), - quality: null, - deviceTimestampUtc: null, - readAtUtc: DateTimeOffset.UtcNow); + companion.Quality, + companion.DeviceTimestampUtc, + readAtUtc); } await Task.Delay(PollInterval, cancellationToken).ConfigureAwait(false); From bd5e039832d72865b5007cbdbde4647a664dde0b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 10:40:24 +0700 Subject: [PATCH 67/77] G2.6: regress independent polling q/t evidence --- ...6ShadowPhysicalCollectorRegressionTests.cs | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs index e4b5ac6b..28597366 100644 --- a/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs +++ b/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs @@ -33,19 +33,57 @@ public void Collector_RequiresTwoPhasesAndOneDeliberateReconnectWithBothPathsRec } [Fact] - public void Collector_DoesNotSynthesizeQualityTimestampOrUseHeaderTimeAsDeviceTimestamp() + public void Collector_KeepsReportMetadataPhysicalAndNeverUsesHeaderTimeAsDeviceTimestamp() { var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); - Assert.Contains("missing report-side quality/timestamp evidence is never inferred", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Report quality/timestamp evidence is accepted only when it is physically carried", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("MmsReportValueProjector.Project(frame)", source, StringComparison.Ordinal); Assert.Contains("projected?.HasQuality == true", source, StringComparison.Ordinal); Assert.Contains("projected?.HasTimestamp == true", source, StringComparison.Ordinal); - Assert.Contains("quality: null", source, StringComparison.Ordinal); - Assert.Contains("deviceTimestampUtc: null", source, StringComparison.Ordinal); Assert.DoesNotContain("frame.Header.TimeOfEntry", source, StringComparison.Ordinal); } + [Fact] + public void Collector_ReadsPollQualityAndTimestampFromExactIndependentCompanions() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + var helper = Read("Services/DynamicReportShadowPollingCompanionReader.cs"); + + Assert.Contains("DynamicReportShadowPollingCompanionReader.ReadAsync", source, StringComparison.Ordinal); + Assert.Contains("pollDiscovery.IedDirectory", source, StringComparison.Ordinal); + Assert.Contains("companion.Quality", source, StringComparison.Ordinal); + Assert.Contains("companion.DeviceTimestampUtc", source, StringComparison.Ordinal); + + Assert.Contains(".stVal", helper, StringComparison.Ordinal); + Assert.Contains("qualityReference = dataObjectReference + \".q\"", helper, StringComparison.Ordinal); + Assert.Contains("timestampReference = dataObjectReference + \".t\"", helper, StringComparison.Ordinal); + Assert.Contains("directory.TryFindByMmsReference", helper, StringComparison.Ordinal); + Assert.Contains("Iec61850QualityDecoder.Decode(read.Value)", helper, StringComparison.Ordinal); + Assert.Contains("Iec61850TimestampDecoder.Decode(read.Value)", helper, StringComparison.Ordinal); + Assert.Contains("ReadSingleVariableAsync(qualityPoint.ToObjectReference()", helper, StringComparison.Ordinal); + Assert.Contains("ReadSingleVariableAsync(timestampPoint.ToObjectReference()", helper, StringComparison.Ordinal); + Assert.Contains("utcTime.Value.ToUniversalTime()", helper, StringComparison.Ordinal); + Assert.DoesNotContain("DateTimeOffset.UtcNow", helper, StringComparison.Ordinal); + Assert.DoesNotContain("TimeOfEntry", helper, StringComparison.Ordinal); + Assert.DoesNotContain("MmsReportValueProjector", helper, StringComparison.Ordinal); + } + + [Fact] + public void PollCompanionReader_IsBoundedReadOnlyAndFailClosed() + { + var helper = Read("Services/DynamicReportShadowPollingCompanionReader.cs"); + + Assert.Contains("at most one q read plus one t read", helper, StringComparison.OrdinalIgnoreCase); + Assert.Contains("quality = null", helper, StringComparison.Ordinal); + Assert.Contains("DateTimeOffset? deviceTimestampUtc = null", helper, StringComparison.Ordinal); + Assert.Contains("if (decoded.IsDecoded)", helper, StringComparison.Ordinal); + Assert.Contains("if (decoded.IsDecoded && TryFindUtcTime", helper, StringComparison.Ordinal); + Assert.DoesNotContain("Write", helper, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControl", helper, StringComparison.Ordinal); + Assert.DoesNotContain("RecordReport", helper, StringComparison.Ordinal); + } + [Fact] public void Collector_PerformsMandatoryMonitorProofFieldAndFreshAssociationCleanup() { From ae1e6b6bf9e86da7e01cd57b13b4fdd762f22017 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 10:42:52 +0700 Subject: [PATCH 68/77] G2.6: document independent polling q/t companions --- docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md b/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md index 0341f686..f191f1e2 100644 --- a/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md +++ b/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md @@ -32,6 +32,9 @@ Each phase uses two independent MMS associations: 2. **Reference association** - read-only direct MMS reads; - exact same proven member sequence; + - exact live `q` and `t` companion objects are read independently when they exist and resolve under the same functional constraint; + - at most one `q` read and one `t` read are attempted per successful primary-value observation; + - companion values are decoded with the ARIEC IEC 61850 quality/timestamp decoders; - no RCB/DataSet access or mutation; - bounded 250 ms polling while the report phase is armed. @@ -65,6 +68,7 @@ The collector records: - independent polling values; - exact report sequence number when supplied; - report-carried quality/timestamp only when ARIEC physically projects those fields from the received InformationReport; +- polling quality/timestamp only when exact live companion objects can be resolved, read and decoded on the isolated polling association; - reconnect attempts and successes; - report re-subscription after reconnect; - polling-reference recovery after reconnect; @@ -75,15 +79,28 @@ The collector records: The currently proven field envelope may contain scalar primary members such as `CSWI1.Pos.stVal`. -A scalar report member does not automatically prove that its data-object quality and timestamp were transported in the same InformationReport. Therefore this phase deliberately does **not**: +For the independent polling authority, ARSAS derives only bounded known IEC data-object sibling paths. For example: + +`AA1C1F08R4Q0/CSWI1.Pos.stVal` + +maps to the independently discovered/read companions: + +- `AA1C1F08R4Q0/CSWI1.Pos.q` +- `AA1C1F08R4Q0/CSWI1.Pos.t` + +These companions are accepted only when the live MMS directory resolves the exact reference under the same functional constraint. A read failure, missing object or decoder failure remains missing evidence. + +A scalar report member still does not automatically prove that its data-object quality and timestamp were transported in the same InformationReport. Therefore this phase deliberately does **not**: - copy polling quality into a report observation; - copy polling timestamps into a report observation; +- copy report quality/timestamp into the polling observation; +- treat polling host read time as the IEC data-object timestamp; - treat report receive time as the IEC data-object timestamp; - treat report header `TimeOfEntry` as the member's device timestamp; -- invent missing q/t from companion reads. +- invent missing q/t when either independent side does not physically supply them. -ARSAS now pins ARIEC61850 PR #99 (`1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f`). The strict production-facing acceptance policy requires actually observed paired report/poll quality evidence and actually observed paired report/poll device timestamp evidence. If the physical report envelope does not carry them, the gate remains fail-closed. That result is useful field evidence, not a software failure to be bypassed. +ARSAS pins ARIEC61850 PR #99 (`1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f`). The strict production-facing acceptance policy requires actually observed paired report/poll quality evidence and actually observed paired report/poll device timestamp evidence. Independent polling companion reads close the polling-side evidence gap, but they do not weaken the report-side requirement: if the physical InformationReport does not transport q/t, the strict gate remains fail-closed. That result is useful field evidence, not a software failure to be bypassed. ## Acceptance layers From ef1a74eb4edccf07c082cbe81eeb37afc72cd87f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:41:45 +0700 Subject: [PATCH 69/77] G2.6: load InformationReportProven guarded dynamic runtime context --- ...50Client.HybridReporting.GuardedRuntime.cs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs new file mode 100644 index 00000000..c7ddbf1d --- /dev/null +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -0,0 +1,105 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private readonly Dictionary _guardedRuntimeContexts = + new(StringComparer.OrdinalIgnoreCase); + + private sealed record GuardedRuntimeContextLoadResult( + ArMms.MmsDynamicReportGuardedRuntimePlanningContext? Context, + string Reason) + { + public bool IsAuthorizedCandidate => Context is not null; + } + + private static async Task TryLoadGuardedRuntimeContextAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var identity = DynamicReportQualificationIdentity.Build(device, device.Signals.ToArray()); + var load = await new DynamicReportQualificationProfileStore() + .LoadAsync(identity, cancellationToken) + .ConfigureAwait(false); + + if (!load.IsValid || load.Profile is null) + { + return new GuardedRuntimeContextLoadResult( + null, + string.IsNullOrWhiteSpace(load.Reason) + ? "No valid identity-compatible dynamic qualification profile is available." + : load.Reason); + } + + if (load.Profile.State < ArMms.MmsDynamicReportQualificationState.InformationReportProven) + { + return new GuardedRuntimeContextLoadResult( + null, + $"Dynamic qualification profile is {load.Profile.State}; guarded Smart Dynamic runtime requires InformationReportProven or stronger evidence."); + } + + if (load.Profile.RcbActivationProof?.IsSuccess != true || + load.Profile.InformationReportProof?.IsSuccess != true || + load.Profile.InformationReportProof.Kind != ArMms.MmsDynamicInformationReportKind.DataChange) + { + return new GuardedRuntimeContextLoadResult( + null, + "Stored dynamic qualification evidence does not contain a successful data-change InformationReport chain; guarded Smart Dynamic runtime remains withheld."); + } + + return new GuardedRuntimeContextLoadResult( + new ArMms.MmsDynamicReportGuardedRuntimePlanningContext + { + Profile = load.Profile, + CurrentIdentity = identity + }, + "Smart Dynamic RCB guarded runtime candidate loaded from identity-compatible InformationReportProven data-change evidence. ProductionEligible certification remains separate."); + } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) + { + return new GuardedRuntimeContextLoadResult( + null, + $"Guarded Smart Dynamic runtime profile could not be trusted: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabilityPlanWithGuardedRuntime( + Iec61850SignalCatalogDocument catalog, + IEnumerable requestedSignals, + ArMms.MmsReportInventory inventory, + ArMms.MmsRcbAvailabilityResult availability, + ArMms.MmsIedModelDirectory liveDirectory, + AR.Iec61850.Acse.AcseMmsNegotiatedCapabilities? negotiatedCapabilities, + ArMms.MmsHybridReportAcquisitionOptions options, + ArMms.MmsDynamicReportGuardedRuntimePlanningContext? guardedContext) + => guardedContext is null + ? ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + options) + : ArMms.MmsGuardedDynamicReportRuntimePlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + options, + guardedContext); + + private bool TryGetGuardedRuntimeContext( + string planId, + out ArMms.MmsDynamicReportGuardedRuntimePlanningContext context) + => _guardedRuntimeContexts.TryGetValue(planId, out context!); +} From 1e9836e8a6ce1bb4b2326fdaadda6dba58a8c4f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:43:10 +0700 Subject: [PATCH 70/77] G2.6: wire guarded Smart Dynamic RCB into normal monitoring --- .../NativeIec61850Client.HybridReporting.cs | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index 746b473d..ccad0983 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -46,6 +46,7 @@ public async Task BuildHybridReportPlansAsync( cancellationToken.ThrowIfCancellationRequested(); _authoritativeHybridSubscriptions.Clear(); + _guardedRuntimeContexts.Clear(); var planningModel = ResolveHybridPlanningModel(device); if (planningModel is null) @@ -196,17 +197,29 @@ public async Task BuildHybridReportPlansAsync( RequireExactAvailabilityEvidence = true }; - // P3: the protocol engine owns capability interpretation. ARSAS supplies the - // current association evidence and consumes the resulting acquisition plan; it - // does not recreate MMS service-bit, RCB ownership, or writability policy locally. - var capabilityAwarePlan = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + // G2.6 runtime boundary: certification and operation are separate. A valid, + // identity-compatible InformationReportProven data-change profile may authorize + // guarded dynamic monitoring only on its exact proven RCB/member envelope. The + // profile is read-only here; no ProductionEligible state is synthesized or saved. + var guardedRuntime = allowDynamicWrites + ? await TryLoadGuardedRuntimeContextAsync(device, cancellationToken).ConfigureAwait(false) + : new GuardedRuntimeContextLoadResult( + null, + dynamicWriteCircuitOpen + ? $"Dynamic writes are circuit-broken after field failure evidence ({dynamicCircuitReason})." + : "Dynamic DataSet writes are disabled for this device."); + + // P3/G2.6: ARIEC remains the protocol/planning authority. ARSAS supplies the + // current association plus optional exact persisted proof and consumes the result. + var capabilityAwarePlan = BuildCapabilityPlanWithGuardedRuntime( catalog, descriptorPoints.Keys, discovery.ReportInventory, availability, discovery.IedDirectory, _session.LastNegotiatedCapabilities, - plannerOptions); + plannerOptions, + guardedRuntime.Context); var enginePlan = capabilityAwarePlan.AcquisitionPlan; var associationCapability = capabilityAwarePlan.AssociationCapability; var p4AttemptEvidence = ArMms.MmsHybridDynamicAttemptEvidenceBuilder.Build(capabilityAwarePlan, plannerOptions); @@ -250,6 +263,8 @@ public async Task BuildHybridReportPlansAsync( catalog, segment.Signals.ToArray(), plannerOptions); + if (guardedRuntime.Context is not null) + _guardedRuntimeContexts[appPlan.PlanId] = guardedRuntime.Context; reportPlans.Add(appPlan); } @@ -282,6 +297,15 @@ 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 (guardedRuntime.IsAuthorizedCandidate) + { + p6Warnings.Add( + "G2.6 Smart Dynamic RCB guarded runtime is authorized from identity-compatible InformationReportProven data-change evidence. Only the exact proven RCB/member envelope may be mutated; ProductionEligible certification remains separate."); + } + else if (device.AllowDynamicDataSetWrites && !dynamicWriteCircuitOpen) + { + p6Warnings.Add($"G2.6 Smart Dynamic RCB guarded runtime is not available: {guardedRuntime.Reason}"); + } if (activationPlans.Count > 1 && activationPlans[0].AllowDynamicDataSetWrites && activationPlans.Any(plan => !plan.AllowDynamicDataSetWrites)) { p6Warnings.Add( @@ -309,6 +333,7 @@ public async Task BuildHybridReportPlansAsync( Authority = $"ARIEC61850 capability-aware hybrid acquisition ({catalogAuthority})", Status = enginePlan.Status.ToString(), Summary = $"{enginePlan.Summary} {associationCapability.Summary}" + + (guardedRuntime.IsAuthorizedCandidate ? " Guarded Smart Dynamic runtime=InformationReportProven exact envelope." : string.Empty) + (staticInventoryMappedCount > 0 ? $" Static inventory bridge={staticInventoryMappedCount}." : string.Empty) + (dynamicWriteCircuitOpen ? " Dynamic writes circuit-broken after field failure evidence." : string.Empty), ReportPlans = activationPlans, @@ -440,9 +465,9 @@ ArMms.MmsHybridAcquisitionKind.DynamicBrcb or // Planning is intentionally an intent, not permission to write forever. // Re-read the exact selected RCB immediately before execution, then ask the same - // ARIEC capability-aware planner to classify that fresh association evidence again. - // This is especially important for SCL fast-connect, where the typed catalog may be - // design-sourced but execution authority must always be live-sourced. + // ARIEC planner family to classify that fresh association evidence again. Guarded + // InformationReportProven authority, when present, is carried by PlanId so the + // execution gate cannot silently broaden or lose the exact proven envelope. var callerOwned = _reportMonitorSessions.Values .Select(session => session.ReportControl.Reference) .Where(reference => !string.IsNullOrWhiteSpace(reference)) @@ -488,14 +513,16 @@ ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ReportControls = selectedSnapshots, Warnings = freshAvailability.Warnings }; - var revalidatedCapabilityAwarePlan = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + TryGetGuardedRuntimeContext(plan.PlanId, out var guardedRuntimeContext); + var revalidatedCapabilityAwarePlan = BuildCapabilityPlanWithGuardedRuntime( authoritative.Catalog, authoritative.Signals, discovery.ReportInventory, selectedAvailability, discovery.IedDirectory, _session.LastNegotiatedCapabilities, - authoritative.Options); + authoritative.Options, + guardedRuntimeContext); var revalidatedPlan = revalidatedCapabilityAwarePlan.AcquisitionPlan; var revalidatedSegment = revalidatedPlan.Segments.FirstOrDefault(segment => segment.IsReportBacked && @@ -627,7 +654,9 @@ segment.ReportPlan is not null && FailureReason = string.Empty, ReportControlReference = plan.ReportControlReference, DataSetReference = plan.DataSetReference, - AcquisitionLabel = $"ARIEC Hybrid: {authoritative.Kind}", + AcquisitionLabel = isDynamic && guardedRuntimeContext is not null + ? $"ARIEC Smart Dynamic: {authoritative.Kind} • InformationReportProven" + : $"ARIEC Hybrid: {authoritative.Kind}", CoveredReferences = coveredReferences, Warnings = attemptWarnings }; From b936e4409098a5f609e768bf9f017217dd51a72e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:43:51 +0700 Subject: [PATCH 71/77] G2.6: preserve guarded context through static-to-dynamic recovery --- .../NativeIec61850Client.HybridReporting.P4.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.P4.cs b/Services/NativeIec61850Client.HybridReporting.P4.cs index b0012575..b982b360 100644 --- a/Services/NativeIec61850Client.HybridReporting.P4.cs +++ b/Services/NativeIec61850Client.HybridReporting.P4.cs @@ -13,6 +13,8 @@ public sealed partial class NativeIec61850Client /// - the failed static RCB is excluded from the recovery availability evidence; /// - static RCBs are disabled in the recovery planner, so only an alternate dynamic /// BRCB/URCB can be selected; + /// - InformationReportProven guarded-runtime authority is preserved by the original + /// PlanId, so recovery may select only the exact already-proven dynamic RCB/member set; /// - a post-mutation static failure may recover only after rollback/cleanup is proven; /// - ARIEC capability + exact availability evidence remains authoritative; /// - StartHybridReportMonitorAsync performs another fresh discovery/revalidation before @@ -113,14 +115,19 @@ private async Task TryStartDynamicRecoveryAfterS RequireExactAvailabilityEvidence = true }; - var recoveryCapability = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + // Preserve the same InformationReportProven context carried by this PlanId. Without + // it the normal ProductionEligible-only planner would re-quarantine dynamic recovery; + // with it ARIEC still restricts recovery to the exact proven RCB/member envelope. + TryGetGuardedRuntimeContext(appPlan.PlanId, out var guardedRuntimeContext); + var recoveryCapability = BuildCapabilityPlanWithGuardedRuntime( authoritative.Catalog, authoritative.Signals, discovery.ReportInventory, alternateAvailability, discovery.IedDirectory, _session.LastNegotiatedCapabilities, - recoveryOptions); + recoveryOptions, + guardedRuntimeContext); var dynamicSegment = recoveryCapability.AcquisitionPlan.Segments.FirstOrDefault(segment => segment.IsReportBacked && @@ -140,11 +147,12 @@ segment.ReportPlan is not null && } // Preserve the runtime plan identity while replacing only its acquisition target. - // Runtime dictionaries, report slice routing and PointPlanIds therefore continue to - // refer to one plan even though Smart Auto escalated static -> dynamic. + // Runtime dictionaries, guarded qualification authority, report slice routing and + // PointPlanIds therefore continue to refer to one plan even though Smart Auto + // escalated static -> dynamic. appPlan.ReportControlReference = dynamicSegment.ReportControlReference; appPlan.DataSetReference = dynamicSegment.DataSetReference; - appPlan.Mode = $"ARIEC Hybrid • {dynamicSegment.Kind} • static recovery"; + appPlan.Mode = $"ARIEC Smart Dynamic • {dynamicSegment.Kind} • static recovery"; appPlan.AllowDynamicDataSetWrites = true; appPlan.Buffered = dynamicSegment.Kind == ArMms.MmsHybridAcquisitionKind.DynamicBrcb; appPlan.Status = $"{dynamicSegment.Kind} recovery planned"; From 43118fb9b362cf7cd4a5933dd402d279fc68bed4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:44:17 +0700 Subject: [PATCH 72/77] G2.6: pin guarded dynamic runtime engine PR 100 --- 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 6177e9fd..13723ef7 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f", - "sourcePullRequest": 99, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 closes the engine-side production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. PR #98, merged on main at ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa, adds the pure typed G2.6 report-vs-independent-MMS shadow evaluator: exact DataSet index/member identity, value/quality/timestamp parity, report ordering, missing/duplicate edge detection, reconnect recovery, polling authority, and bounded dynamic-activation loop evidence. It can build the existing production-acceptance contract only from a successful shadow and never mutates a profile. PR #99, merged on main at 1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f, hardens the production-facing bridge so QualityRegressionPassed additionally requires actually observed paired report/poll quality evidence and actually observed paired report/poll device timestamp evidence; absence of q/t evidence cannot become a production PASS. ARSAS pins this immutable main commit for physical shadow commissioning; the current field profile remains InformationReportProven and production automatic dynamic reporting remains OFF until the physical shadow plus independent Smart Control/static-report regressions explicitly justify a later ProductionEligible transition." + "commit": "c899b05f18ba2bd4c82ebff6879e4748036e0d90", + "sourcePullRequest": 100, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. 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 the resolved runtime primary leaf from 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; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. PR #97 closes the engine-side production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. PR #98, merged on main at ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa, adds the pure typed G2.6 report-vs-independent-MMS shadow evaluator: exact DataSet index/member identity, value/quality/timestamp parity, report ordering, missing/duplicate edge detection, reconnect recovery, polling authority, and bounded dynamic-activation loop evidence. It can build the existing production-acceptance contract only from a successful shadow and never mutates a profile. PR #99, merged on main at 1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f, hardens the production-facing bridge so QualityRegressionPassed additionally requires actually observed paired report/poll quality evidence and actually observed paired report/poll device timestamp evidence; absence of q/t evidence cannot become a production PASS. PR #100, merged on main at c899b05f18ba2bd4c82ebff6879e4748036e0d90, adds a separate guarded runtime planner: an identity-compatible InformationReportProven data-change profile may drive at most one exact proven dynamic RCB/member envelope while static reporting remains eligible and all unproven points remain on polling. This guarded runtime does not call MarkProductionEligible, does not synthesize production acceptance, and keeps ProductionEligible as a separate certification boundary." } From edab0afc54f0aacb0c65a9690168974a726a4958 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:45:22 +0700 Subject: [PATCH 73/77] G2.6: update Smart Auto recovery regression for guarded runtime --- ...idReportDynamicAttemptP4RegressionTests.cs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs b/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs index b4db0e73..c141cc47 100644 --- a/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs +++ b/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs @@ -19,16 +19,18 @@ public void Planning_ProjectsEngineAttemptEvidenceInsteadOfSilentPolling() } [Fact] - public void StaticFailure_GetsGuardedAlternateDynamicRecoveryBeforePolling() + public void StaticFailure_GetsGuardedExactDynamicRecoveryBeforePolling() { var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); Assert.Contains("StartPersistentReportMonitorWithAttemptEvidenceAsync", bridge, StringComparison.Ordinal); Assert.True(Count(bridge, "TryStartDynamicRecoveryAfterStaticFailureP4Async") >= 4); - // G2.6 may recover a failed static segment, but only through the ARIEC planner and - // a different RCB with fresh availability evidence. P4 never writes an RCB directly. + // G2.6 may recover a failed static segment, but only through the ARIEC guarded + // planner, the same PlanId-bound InformationReportProven context, and a different + // freshly classified RCB. P4 never writes an RCB directly. Assert.Contains("alternateSnapshots", recovery, StringComparison.Ordinal); Assert.Contains("!SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)", recovery, StringComparison.Ordinal); Assert.Contains("AllowStaticBrcb = false", recovery, StringComparison.Ordinal); @@ -36,7 +38,9 @@ public void StaticFailure_GetsGuardedAlternateDynamicRecoveryBeforePolling() Assert.Contains("AllowDynamicBrcb = authoritative.Options.AllowDynamicBrcb", recovery, StringComparison.Ordinal); Assert.Contains("AllowDynamicUrcb = authoritative.Options.AllowDynamicUrcb", recovery, StringComparison.Ordinal); Assert.Contains("RequireExactAvailabilityEvidence = true", recovery, StringComparison.Ordinal); - Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", recovery, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("_authoritativeHybridSubscriptions[appPlan.PlanId]", recovery, StringComparison.Ordinal); Assert.Contains("return await StartHybridReportMonitorAsync(appPlan", recovery, StringComparison.Ordinal); Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); @@ -57,7 +61,7 @@ public void StaticPostMutationRecovery_RequiresProvenCleanup() } [Fact] - public void DynamicRecovery_RetainsCircuitBreakerAndPlanIdentity() + public void DynamicRecovery_RetainsCircuitBreakerPlanIdentityAndGuardedAuthority() { var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); @@ -66,6 +70,7 @@ public void DynamicRecovery_RetainsCircuitBreakerAndPlanIdentity() Assert.Contains("DynamicWriteCircuitOpen", recovery, StringComparison.Ordinal); Assert.Contains("appPlan.EngineAcquisitionKind = dynamicSegment.Kind.ToString()", recovery, StringComparison.Ordinal); Assert.Contains("_authoritativeHybridSubscriptions[appPlan.PlanId]", recovery, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); Assert.Contains("DynamicWriteCircuitByDevice[plan.RelayId] = reason", bridge, StringComparison.Ordinal); } @@ -85,12 +90,15 @@ public void PhysicalValidation_PersistsAttemptFailureAndSkipTelemetry() } [Fact] - public void EngineLock_PinsAttemptAwareEngine() + public void EngineLock_PinsAttemptAwareAndGuardedRuntimeEngine() { var engineLock = Read("engines/ARIEC61850.lock.json"); Assert.Contains("dynamic-attempt", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("rollback", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", engineLock, StringComparison.Ordinal); + Assert.Contains("c899b05f18ba2bd4c82ebff6879e4748036e0d90", engineLock, StringComparison.Ordinal); + Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); } private static int Count(string source, string value) From b0a5ba60f7a6f7d73d49b9ddd3f5277ac9824d5c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:46:14 +0700 Subject: [PATCH 74/77] G2.6: add guarded Smart Dynamic runtime regressions --- .../G26SmartDynamicRuntimeRegressionTests.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs new file mode 100644 index 00000000..3080432c --- /dev/null +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -0,0 +1,116 @@ +namespace ARSAS.Tests; + +public sealed class G26SmartDynamicRuntimeRegressionTests +{ + [Fact] + public void NormalMonitoring_LoadsIdentityCompatibleInformationReportProvenContext() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("DynamicReportQualificationIdentity.Build(device, device.Signals.ToArray())", guarded, StringComparison.Ordinal); + Assert.Contains("DynamicReportQualificationProfileStore", guarded, StringComparison.Ordinal); + Assert.Contains("LoadAsync(identity", guarded, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportQualificationState.InformationReportProven", guarded, StringComparison.Ordinal); + Assert.Contains("MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); + Assert.Contains("TryLoadGuardedRuntimeContextAsync(device", bridge, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportGuardedRuntimePlanningContext", guarded, StringComparison.Ordinal); + } + + [Fact] + public void InitialPlanningAndExecutionRevalidation_UseSameGuardedPlannerFamily() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.True(Count(bridge, "BuildCapabilityPlanWithGuardedRuntime(") >= 2); + Assert.Contains("_guardedRuntimeContexts[appPlan.PlanId] = guardedRuntime.Context", bridge, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(plan.PlanId", bridge, StringComparison.Ordinal); + Assert.Contains("guardedRuntimeContext", bridge, StringComparison.Ordinal); + } + + [Fact] + public void GuardedRuntime_DoesNotPromoteOrSaveQualificationProfile() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + Assert.DoesNotContain("MarkProductionEligible(", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", recovery, StringComparison.Ordinal); + Assert.Contains("ProductionEligible certification remains separate", guarded, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeStillHasFreshRevalidationCircuitBreakerAndPollingFallback() + { + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + + Assert.Contains("CheckReportControlAvailabilityAsync", bridge, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice", bridge, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice", recovery, StringComparison.Ordinal); + Assert.Contains("AllowPollingFallback = true", bridge, StringComparison.Ordinal); + Assert.Contains("AllowPollingFallback = true", recovery, StringComparison.Ordinal); + Assert.Contains("value changed without matching report", runtime, StringComparison.Ordinal); + Assert.Contains("MMS fallback", runtime, StringComparison.Ordinal); + Assert.Contains("Live / report verified + MMS validation", runtime, StringComparison.Ordinal); + } + + [Fact] + public void StaticRecovery_PreservesPlanBoundGuardedContext() + { + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", recovery, StringComparison.Ordinal); + Assert.Contains("_authoritativeHybridSubscriptions[appPlan.PlanId]", recovery, StringComparison.Ordinal); + Assert.Contains("return await StartHybridReportMonitorAsync(appPlan", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", recovery, StringComparison.Ordinal); + } + + [Fact] + public void EngineLock_PinsMergedGuardedRuntimeEngine() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("\"commit\": \"c899b05f18ba2bd4c82ebff6879e4748036e0d90\"", engineLock, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 100", engineLock, StringComparison.Ordinal); + Assert.Contains("guarded runtime planner", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); + } + + private static int Count(string source, string value) + { + var count = 0; + var offset = 0; + while ((offset = source.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} From 8a671a68efc7db62f92d04bea280a9edf6e1f183 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:46:35 +0700 Subject: [PATCH 75/77] G2.6: document normal Smart Dynamic RCB runtime --- docs/G2_6_SMART_DYNAMIC_RUNTIME.md | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/G2_6_SMART_DYNAMIC_RUNTIME.md diff --git a/docs/G2_6_SMART_DYNAMIC_RUNTIME.md b/docs/G2_6_SMART_DYNAMIC_RUNTIME.md new file mode 100644 index 00000000..53349334 --- /dev/null +++ b/docs/G2_6_SMART_DYNAMIC_RUNTIME.md @@ -0,0 +1,79 @@ +# G2.6 Smart Dynamic RCB Runtime + +## Goal + +Smart Dynamic RCB is a normal monitoring acquisition path. It is not a commissioning ceremony. + +After an IED has an identity-compatible `InformationReportProven` profile with a successful data-change InformationReport, ARSAS may use the exact already-proven dynamic RCB/member envelope during ordinary monitoring without requiring `ProductionEligible` certification. + +`ProductionEligible` remains a separate certification boundary and is never synthesized or persisted by this runtime path. + +## Operator workflow + +There is no G2.6 commissioning hotkey in the normal runtime workflow. + +1. Connect the qualified IED normally. +2. Select the required proven signals. +3. Start Monitor. + +ARSAS then performs the acquisition decision automatically. + +## Runtime order + +The ARIEC planner remains authoritative: + +`configured static RCB -> guarded exact proven dynamic RCB -> MMS polling residual/fallback` + +Static DataSet-backed reporting keeps normal coverage precedence. For residual points that are inside the exact proven InformationReport envelope, guarded dynamic reporting may use only: + +- the exact RCB stored in the successful activation + InformationReport evidence; +- the exact ordered InformationReport-proven member envelope; +- at most one dynamic RCB group. + +Anything outside that envelope remains on MMS polling. + +## Guarded dynamic authorization + +Before dynamic planning ARSAS loads the persisted qualification profile using the current stable IED identity/model fingerprint. ARIEC revalidates: + +- current association dynamic-report capability; +- profile schema and identity compatibility; +- state `InformationReportProven` or stronger; +- successful RCB activation evidence; +- successful actual `DataChange` InformationReport evidence; +- exact RCB/DataSet identity consistency; +- exact ordered member consistency with the accepted envelope. + +No alternate free RCB may substitute for the proven RCB. + +## Fresh execution gate + +Planning does not grant indefinite write permission. Immediately before activation ARSAS performs fresh report discovery and fresh RCB availability checks, then runs the same guarded ARIEC planner again with the PlanId-bound qualification context. + +If the exact dynamic segment cannot be reproduced, no dynamic write occurs and MMS polling remains active. + +## Runtime report + MMS validation + +When the dynamic RCB activates successfully, InformationReport traffic drives the live process values. The existing ARSAS runtime continues MMS verification/reconciliation. If MMS detects a process-value change that was not delivered by the armed report, the point is degraded to MMS fallback until report delivery is verified again. + +This is intentionally simpler than the physical shadow collector: report quality/timestamp certification is not a prerequisite for guarded runtime operation when the actual proven DataSet carries scalar process values such as `stVal`. + +## Failure handling + +A real dynamic activation failure opens the existing per-device, process-lifetime dynamic-write circuit breaker. ARSAS does not repeatedly mutate the RCB. Static reporting remains eligible and affected residual points use bounded MMS polling. + +Static-to-dynamic recovery also preserves the original PlanId-bound guarded context. Recovery therefore cannot select an arbitrary alternate dynamic RCB; it is still restricted to the exact InformationReport-proven RCB/member envelope and requires proven cleanup if a failed static activation already mutated RCB state. + +## State boundary + +This runtime path performs no qualification profile save and never calls `MarkProductionEligible`. + +The persisted profile may remain: + +`InformationReportProven` + +while guarded Smart Dynamic RCB is used for normal monitoring. + +This means: + +`Smart Dynamic runtime authorized != ProductionEligible certification` From aad3b5a3618c04162b1677fa93491c549cffac3b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:48:05 +0700 Subject: [PATCH 76/77] G2.6: import ARIEC discovery contracts for guarded runtime --- Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs index c7ddbf1d..a1bae5e8 100644 --- a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArMms = AR.Iec61850.Mms; From aca2c45767c7349eb9580c71ca2cb17e8321021d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 26 Aug 2026 14:57:09 +0700 Subject: [PATCH 77/77] G2.6: align regressions with guarded Smart Dynamic runtime boundary --- ...DrivenSessionLiveMonitorRegressionTests.cs | 10 +++++--- tests/ARSAS.Tests/FieldRegressionFixTests.cs | 5 +++- .../G1ControlCorrectnessRegressionTests.cs | 23 +++++++++++-------- .../G26P1DeterministicA3RegressionTests.cs | 10 ++++---- ...owVerificationAcceptanceRegressionTests.cs | 13 +++++++---- ...tAssociationCapabilityP3RegressionTests.cs | 9 ++++++-- .../P62BFieldStabilityRegressionTests.cs | 9 ++++---- .../P6FieldStabilityRegressionTests.cs | 6 +++-- 8 files changed, 55 insertions(+), 30 deletions(-) diff --git a/tests/ARSAS.Tests/EventDrivenSessionLiveMonitorRegressionTests.cs b/tests/ARSAS.Tests/EventDrivenSessionLiveMonitorRegressionTests.cs index 081a5b57..a52fe71b 100644 --- a/tests/ARSAS.Tests/EventDrivenSessionLiveMonitorRegressionTests.cs +++ b/tests/ARSAS.Tests/EventDrivenSessionLiveMonitorRegressionTests.cs @@ -39,9 +39,10 @@ public void LiveMonitor_HasFullWidthGlobalSearch_AndNoDuplicateSummaryBadge() } [Fact] - public void SclMonitoring_UsesAriecHybridStaticAndCircuitBrokenDynamicReports_BeforeResidualPolling() + public void SclMonitoring_UsesAriecHybridStaticAndGuardedDynamicReports_BeforeResidualPolling() { var bridge = File.ReadAllText(FindRepoFile(Path.Combine("Services", "NativeIec61850Client.HybridReporting.cs"))); + var guarded = File.ReadAllText(FindRepoFile(Path.Combine("Services", "NativeIec61850Client.HybridReporting.GuardedRuntime.cs"))); var models = File.ReadAllText(FindRepoFile(Path.Combine("Models", "MonitorModels.cs"))); Assert.Contains("AllowStaticBrcb = true", bridge, StringComparison.Ordinal); @@ -50,7 +51,10 @@ public void SclMonitoring_UsesAriecHybridStaticAndCircuitBrokenDynamicReports_Be Assert.Contains("AllowDynamicBrcb = allowDynamicWrites", bridge, StringComparison.Ordinal); Assert.Contains("AllowDynamicUrcb = allowDynamicWrites", bridge, StringComparison.Ordinal); Assert.Contains("DynamicWriteCircuitByDevice", bridge, StringComparison.Ordinal); - Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", bridge, StringComparison.Ordinal); + Assert.Contains("TryLoadGuardedRuntimeContextAsync(device", bridge, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", bridge, StringComparison.Ordinal); + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsHybridDynamicAttemptEvidenceBuilder.Build", bridge, StringComparison.Ordinal); Assert.Contains("_session.LastNegotiatedCapabilities", bridge, StringComparison.Ordinal); Assert.Contains("StartPersistentReportMonitorWithAttemptEvidenceAsync", bridge, StringComparison.Ordinal); @@ -79,4 +83,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} \ No newline at end of file +} diff --git a/tests/ARSAS.Tests/FieldRegressionFixTests.cs b/tests/ARSAS.Tests/FieldRegressionFixTests.cs index f15e789e..2d4eded0 100644 --- a/tests/ARSAS.Tests/FieldRegressionFixTests.cs +++ b/tests/ARSAS.Tests/FieldRegressionFixTests.cs @@ -49,6 +49,7 @@ public void TopBar_ParentContainersCannotClipResponsiveNavigation() public void SclFastConnect_UsesTypedDesignModelForHybridCatalog_ButKeepsFreshLiveRcbValidation() { var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.HybridReporting.cs")); + var guarded = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs")); Assert.Contains( "device?.LiveDiscoveryModel ?? device?.SclWorkspace?.DesignModel", @@ -60,7 +61,9 @@ public void SclFastConnect_UsesTypedDesignModelForHybridCatalog_ButKeepsFreshLiv Assert.Contains("CheckReportControlAvailabilityAsync", source, StringComparison.Ordinal); Assert.Contains("RequireExactAvailabilityEvidence = true", source, StringComparison.Ordinal); Assert.Contains("fresh capability-aware engine evidence", source, StringComparison.Ordinal); - Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", source, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", source, StringComparison.Ordinal); + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.DoesNotContain( "CanUseHybridReportPlanner(Iec61850MonitorDevice device)\n => device?.LiveDiscoveryModel is not null", source, diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 3148fe25..7cde5d97 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -5,15 +5,15 @@ namespace ARSAS.Tests; public sealed class G1ControlCorrectnessRegressionTests { [Fact] - public void EngineLock_PinsStrictG26ShadowProductionEvidenceAndPreservesExactG1FieldProvenAncestry() + public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAncestry() { var root = RepoRoot(); using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json"))); var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f", json.GetProperty("commit").GetString()); - Assert.Equal(99, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("c899b05f18ba2bd4c82ebff6879e4748036e0d90", json.GetProperty("commit").GetString()); + Assert.Equal(100, json.GetProperty("sourcePullRequest").GetInt32()); var purpose = json.GetProperty("purpose").GetString() ?? string.Empty; // G2.6 may advance the engine pin only while the field-proven G1/G2.3/P0/P1 ancestry @@ -38,8 +38,8 @@ public void EngineLock_PinsStrictG26ShadowProductionEvidenceAndPreservesExactG1F Assert.Contains("192.168.81.240", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("Owner mismatch or unsupported encoding remains a hard failure", purpose, StringComparison.OrdinalIgnoreCase); - // PR #97 adds the production consumer, PR #98 adds the pure shadow evaluator, - // and PR #99 hardens only the production-facing q/t evidence boundary. + // PR #97 adds the ProductionEligible consumer, PR #98/#99 preserve strict + // certification evidence, and PR #100 adds a separate guarded runtime boundary. Assert.Contains("PR #97", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible profile", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("exact InformationReport-proven RCB/member evidence", purpose, StringComparison.OrdinalIgnoreCase); @@ -50,8 +50,11 @@ public void EngineLock_PinsStrictG26ShadowProductionEvidenceAndPreservesExactG1F Assert.Contains("actually observed paired report/poll quality evidence", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("actually observed paired report/poll device timestamp evidence", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("absence of q/t evidence cannot become a production PASS", purpose, StringComparison.OrdinalIgnoreCase); - Assert.Contains("current field profile remains InformationReportProven", purpose, StringComparison.OrdinalIgnoreCase); - Assert.Contains("production automatic dynamic reporting remains OFF", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("identity-compatible InformationReportProven", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("at most one exact proven dynamic RCB/member envelope", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("does not call MarkProductionEligible", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", purpose, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -137,12 +140,14 @@ public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl() } [Fact] - public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() + public void G1_ControlPathDoesNotOwnGuardedDynamicRuntimeOrChangeReconnectPolicy() { var engineLock = File.ReadAllText(Path.Combine(RepoRoot(), "engines", "ARIEC61850.lock.json")); Assert.Contains("PR #89 quarantines automatic full dynamic DataSet activation", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("production automatic dynamic reporting remains OFF", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); + // G1 control remains independent from the G2.6 report acquisition bridge. var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); Assert.Contains("SmartReconnectPolicy", runtime, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index 4e4c9c62..61d1bc34 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -168,20 +168,22 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde } [Fact] - public void EngineLock_PinsStrictShadowProductionEvidenceButKeepsCurrentFieldStateLocked() + public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateGuardedRuntimeBoundary() { var engineLock = Read("engines/ARIEC61850.lock.json"); Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 99", engineLock, StringComparison.Ordinal); + Assert.Contains("c899b05f18ba2bd4c82ebff6879e4748036e0d90", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 100", engineLock, StringComparison.Ordinal); Assert.Contains("PR #98", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("report-vs-independent-MMS shadow evaluator", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #99", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("paired report/poll quality evidence", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("paired report/poll device timestamp evidence", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); - Assert.Contains("production automatic dynamic reporting remains OFF", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("does not call MarkProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); } private static int CountOccurrences(string source, string value) diff --git a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs index a592fc87..a9df57cc 100644 --- a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs +++ b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs @@ -71,19 +71,22 @@ public void ShadowAcceptance_LeavesControlAndStaticRegressionAsIndependentInputs } [Fact] - public void EngineLock_PinsMergedPr99MainAndKeepsProductionOff() + public void EngineLock_PreservesPr99StrictCertificationAndPinsPr100GuardedRuntime() { var lockFile = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 99", lockFile, StringComparison.Ordinal); + Assert.Contains("c899b05f18ba2bd4c82ebff6879e4748036e0d90", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 100", lockFile, StringComparison.Ordinal); Assert.Contains("PR #98", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("report-vs-independent-MMS shadow evaluator", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #99", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("paired report/poll quality evidence", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("paired report/poll device timestamp evidence", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("current field profile remains InformationReportProven", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("production automatic dynamic reporting remains OFF", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absence of q/t evidence cannot become a production PASS", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("InformationReportProven", lockFile, StringComparison.Ordinal); + Assert.Contains("does not call MarkProductionEligible", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", lockFile, StringComparison.OrdinalIgnoreCase); } private static string Read(string relativePath) diff --git a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs index 88ff45a7..ec8b4a12 100644 --- a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs +++ b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs @@ -8,15 +8,18 @@ public sealed class HybridReportAssociationCapabilityP3RegressionTests public void HybridPlanning_UsesAssociationCapabilityForInitialPlanAndFreshRevalidation() { var source = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); - const string call = "ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build("; - Assert.Equal(2, Count(source, call)); + Assert.True(Count(source, "BuildCapabilityPlanWithGuardedRuntime(") >= 2); + Assert.Contains("ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build(", guarded, StringComparison.Ordinal); + Assert.Contains("ArMms.MmsGuardedDynamicReportRuntimePlanner.Build(", guarded, StringComparison.Ordinal); Assert.True(Count(source, "_session.LastNegotiatedCapabilities") >= 2); Assert.Contains("var enginePlan = capabilityAwarePlan.AcquisitionPlan;", source, StringComparison.Ordinal); Assert.Contains("var associationCapability = capabilityAwarePlan.AssociationCapability;", source, StringComparison.Ordinal); Assert.Contains("Summary = $\"{enginePlan.Summary} {associationCapability.Summary}\"", source, StringComparison.Ordinal); Assert.Contains("var revalidatedPlan = revalidatedCapabilityAwarePlan.AcquisitionPlan;", source, StringComparison.Ordinal); Assert.DoesNotContain("ArMms.MmsHybridReportAcquisitionPlanner.Build(", source, StringComparison.Ordinal); + Assert.DoesNotContain("ArMms.MmsHybridReportAcquisitionPlanner.Build(", guarded, StringComparison.Ordinal); } [Fact] @@ -44,6 +47,8 @@ public void EngineLock_PreservesP62BStabilityHistoryAcrossLaterReviewedEnginePin Assert.Contains("instMag/mag", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("instCVal/cVal", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("ambiguous structures remain raw", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact proven dynamic RCB/member envelope", source, StringComparison.OrdinalIgnoreCase); } private static int Count(string source, string value) diff --git a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs index 39d4fe97..1cd82fdc 100644 --- a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs @@ -93,12 +93,13 @@ public void G26SmartRecovery_DoesNotRegressP62BMutationQuarantine() var bridge = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.cs"); var recovery = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); - // P4 is still not a wire writer. It can only ask the capability-aware planner for - // an alternate target, replace the authoritative plan, then re-enter the normal - // StartHybrid path where fresh availability and the dynamic circuit are enforced. + // P4 is still not a wire writer. It can only ask the guarded/capability planner for + // the PlanId-bound proven target, replace the authoritative plan, then re-enter the + // normal StartHybrid path where fresh availability and the dynamic circuit are enforced. Assert.DoesNotContain("DefineNamedVariableList", recovery, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); - Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", recovery, StringComparison.Ordinal); Assert.Contains("StartHybridReportMonitorAsync(appPlan", recovery, StringComparison.Ordinal); Assert.Contains("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", recovery, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs index 7da3ef80..91776721 100644 --- a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs @@ -137,13 +137,15 @@ public void StaticFailure_RecoveryPreservesP6FieldSafety() var recovery = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); // Recovery cannot reuse the failed static RCB and cannot write directly from the - // compatibility layer. ARIEC must plan an alternate dynamic target from fresh data. + // compatibility layer. The same PlanId-bound ARIEC guarded/capability planner must + // select the only exact proven alternate dynamic target from fresh evidence. Assert.Contains("alternateSnapshots", recovery, StringComparison.Ordinal); Assert.Contains("!SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)", recovery, StringComparison.Ordinal); Assert.Contains("AllowStaticBrcb = false", recovery, StringComparison.Ordinal); Assert.Contains("AllowStaticUrcb = false", recovery, StringComparison.Ordinal); Assert.Contains("RequireExactAvailabilityEvidence = true", recovery, StringComparison.Ordinal); - Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", recovery, StringComparison.Ordinal); Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); Assert.DoesNotContain("DefineNamedVariableList", recovery, StringComparison.OrdinalIgnoreCase);