From 495d533ce90a3f0cd1d04dd81073495a3dc81a69 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 24 Aug 2026 11:29:43 +0700 Subject: [PATCH 001/150] 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 09a3da8ae..2fdc19779 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 002/150] 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 2fdc19779..b0012575a 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 003/150] 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 99b3b0ba6..746b473de 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 004/150] 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 037ef5972..b4db0e730 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 005/150] 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 f9e22ee25..7da3ef800 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 006/150] 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 3910c301c..39d4fe976 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 007/150] 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 268797b4a..88fef07b1 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 008/150] 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 000000000..b20c9a084 --- /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 009/150] 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 000000000..3c4ff462a --- /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 010/150] 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 f58e4e924..ee90ed28f 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 011/150] 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 000000000..3c2a5da92 --- /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 012/150] 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 000000000..d67407a60 --- /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 013/150] 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 88fef07b1..08bc0da5c 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 014/150] 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 3c2a5da92..e26471fef 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 015/150] 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 e26471fef..74e9f108b 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 016/150] 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 f30b60e40..8f75c9552 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 017/150] 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 74e9f108b..1fda0b854 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 018/150] 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 000000000..b418256c9 --- /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 019/150] 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 ee90ed28f..5d0843723 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 020/150] 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 000000000..31a22dc62 --- /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 021/150] 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 d67407a60..fe4b7347d 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 022/150] 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 000000000..6a21ab018 --- /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 023/150] 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 000000000..03de11c8e --- /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 024/150] 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 5d0843723..67075e214 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 025/150] 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 1fda0b854..ffa9de014 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 026/150] 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 fe4b7347d..0ea650221 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 027/150] 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 03de11c8e..d1d5431cf 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 028/150] 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 d1d5431cf..480114b0b 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 029/150] 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 ffa9de014..a563d86d0 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 030/150] 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 31a22dc62..28a2667a8 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 031/150] 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 e97c1f187..8e5191956 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 032/150] 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 b20c9a084..10cea36b9 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 033/150] 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 a563d86d0..a4ca7532d 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 034/150] 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 0ea650221..385aaf9f8 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 035/150] 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 385aaf9f8..aa9c4ecfc 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 036/150] 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 aa9c4ecfc..c7eb3d0f8 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 037/150] 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 038/150] 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 039/150] 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 040/150] 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 041/150] 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 042/150] 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 043/150] 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 044/150] 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 08bc0da5c..a129827f8 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 045/150] 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 000000000..7e83823d5 --- /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 046/150] 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 a4ca7532d..e58443549 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 047/150] 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 8f75c9552..560f6620e 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 048/150] 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 000000000..bdac4a39d --- /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 049/150] 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 000000000..85fbd8b38 --- /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 050/150] 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 000000000..1fd75a1fa --- /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 051/150] 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 000000000..fa65c8d28 --- /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 052/150] 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 a129827f8..6177e9fd4 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 053/150] 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 7e83823d5..53c410777 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 054/150] 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 560f6620e..3148fe258 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 055/150] 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 bdac4a39d..a592fc879 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 056/150] 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 e58443549..4e4c9c623 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 057/150] 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 000000000..beeb01c15 --- /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 058/150] 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 000000000..b9e48b731 --- /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 059/150] 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 000000000..be1522d66 --- /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 060/150] 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 67075e214..b7e6f2d44 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 061/150] 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 000000000..0341f686f --- /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 062/150] 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 000000000..54ce06721 --- /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 063/150] 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 beeb01c15..7894aeaf0 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 064/150] 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 b9e48b731..e4b5ac6b6 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 065/150] 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 000000000..1ed61b776 --- /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 066/150] 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 7894aeaf0..42376e977 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 067/150] 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 e4b5ac6b6..28597366b 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 068/150] 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 0341f686f..f191f1e20 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 069/150] 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 000000000..c7ddbf1df --- /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 070/150] 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 746b473de..ccad0983a 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 071/150] 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 b0012575a..b982b360b 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 072/150] 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 6177e9fd4..13723ef79 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 073/150] 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 b4db0e730..c141cc47a 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 074/150] 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 000000000..3080432ca --- /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 075/150] 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 000000000..53349334a --- /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 076/150] 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 c7ddbf1df..a1bae5e8f 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 077/150] 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 081a5b57f..a52fe71bc 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 f15e789ee..2d4eded0b 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 3148fe258..7cde5d97a 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 4e4c9c623..61d1bc34e 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 a592fc879..a9df57ccc 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 88ff45a7b..ec8b4a12a 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 39d4fe976..1cd82fdcd 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 7da3ef800..917767218 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); From 7c07fc224cfcd430f52c11011aeca3f10f4440bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:18:21 +0700 Subject: [PATCH 078/150] G2.6 P1.5: add exact field legacy dchg evidence manifest --- ...rdedLegacyCompatibilityEvidenceRegistry.cs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs diff --git a/Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs b/Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs new file mode 100644 index 000000000..04abf2070 --- /dev/null +++ b/Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs @@ -0,0 +1,126 @@ +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// P1.5 reviewed compatibility manifest for the one legacy field profile whose persisted +/// G2.4 InformationReport proof is GI-classified even though a later physical A3 run proved +/// a real NO-GI spontaneous dchg InformationReport. +/// +/// This is deliberately NOT a wildcard migration. Every identity and envelope field below +/// is exact. Any firmware/model/profile/RCB/member change fails closed and requires new +/// evidence instead of inheriting this compatibility record. +/// +internal static class DynamicReportGuardedLegacyCompatibilityEvidenceRegistry +{ + internal const string ExpectedStableIdentityKey = "ied:AA1C1F08R4"; + internal const string ExpectedModelFingerprint = "sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9"; + internal const string ExpectedProfileRevision = "e5f7fe9b93524f8019ff7cd01f042fc1827ef32e8b930262a2eafbf20ef357c0"; + internal const string ExpectedRcbReference = "AA1C1F08R4ADD/LLN0.RP.A_URCB01"; + internal const string EvidenceId = "arsas-g26-p1.5-aa1c1f08r4-a3-dchg-field-pass-20260824"; + + internal static readonly string[] ExpectedMemberReferences = + [ + "AA1C1F08R4Q0/CSWI1$ST$Pos$stVal", + "AA1C1F08R4Q0/XCBR1$ST$Pos$stVal" + ]; + + internal static bool TryResolve( + ArMms.MmsDynamicReportIedIdentity identity, + ArMms.MmsDynamicReportQualificationProfile profile, + out ArMms.MmsDynamicReportLegacyDataChangeCompatibilityEvidence? evidence, + out string reason) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(profile); + + evidence = null; + + if (!Same(identity.StableIdentityKey, ExpectedStableIdentityKey) || + !Same(identity.ModelFingerprint, ExpectedModelFingerprint) || + !Same(identity.ProfileRevision, ExpectedProfileRevision)) + { + reason = "No reviewed P1.5 legacy compatibility evidence matches the exact current stable identity, model fingerprint and profile revision."; + return false; + } + + if (profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven) + { + reason = $"Reviewed P1.5 legacy compatibility applies only to the retained InformationReportProven field profile; current state is {profile.State}."; + return false; + } + + var envelope = profile.AcceptedEnvelope; + var activation = profile.RcbActivationProof; + var report = profile.InformationReportProof; + if (envelope is null || activation?.IsSuccess != true || report?.IsSuccess != true) + { + reason = "Legacy field profile is missing successful accepted-envelope / activation / InformationReport evidence."; + return false; + } + + if (report.Kind != ArMms.MmsDynamicInformationReportKind.GeneralInterrogation) + { + reason = $"P1.5 legacy compatibility is only for the reviewed GI-classified profile; stored report kind is {report.Kind}."; + return false; + } + + if (!Same(activation.RcbReference, ExpectedRcbReference) || + !Same(report.RcbReference, ExpectedRcbReference)) + { + reason = "Legacy field profile RCB does not exactly match the reviewed A_URCB01 physical evidence."; + return false; + } + + if (!ExactSequence(activation.MemberReferences, ExpectedMemberReferences) || + !ExactSequence(report.MemberReferences, ExpectedMemberReferences) || + !ExactSequence(envelope.ExactProvenMemberReferences, ExpectedMemberReferences)) + { + reason = "Legacy field profile ordered member sequence does not exactly match the reviewed Q0 CSWI/XCBR A3 evidence."; + return false; + } + + // Physical G2.6-P1 A3 evidence supplied during field validation: + // - exact URCB: AA1C1F08R4ADD/LLN0.RP.A_URCB01 + // - temporary DataSet: AA1C1F08R4ADD/LLN0.AR_G25A_4E20EC7E + // - actual spontaneous InformationReport with reason=data-change + // - included DataSet indexes [0,1] mapped in order to the exact CSWI/XCBR members above + // - GI disabled for the dchg proof + // - association healthy after report + // - monitor cleanup, TrgOps/OptFlds restore, and fresh-association closure all passed. + evidence = new ArMms.MmsDynamicReportLegacyDataChangeCompatibilityEvidence + { + EvidenceId = EvidenceId, + StableIdentityKey = ExpectedStableIdentityKey, + ModelFingerprint = ExpectedModelFingerprint, + ProfileRevision = ExpectedProfileRevision, + RcbReference = ExpectedRcbReference, + MemberReferences = ExpectedMemberReferences, + ActualInformationReportReceived = true, + DataChangeReasonVerified = true, + GeneralInterrogationDisabled = true, + ExactMemberMappingVerified = true, + AssociationHealthyAfterReport = true, + CleanupSucceeded = true + }; + reason = "Reviewed AA1C1F08R4 P1.5 legacy compatibility evidence matched exact identity, A_URCB01 and ordered Q0 CSWI/XCBR members."; + return true; + } + + private static bool ExactSequence(IReadOnlyList actual, IReadOnlyList expected) + { + if (actual.Count != expected.Count) + return false; + + for (var index = 0; index < actual.Count; index++) + { + if (!Same(actual[index], expected[index])) + return false; + } + + return true; + } + + private static bool Same(string? left, string? right) + => string.Equals((left ?? string.Empty).Trim(), (right ?? string.Empty).Trim(), StringComparison.OrdinalIgnoreCase); +} From 324423673109fd9c36e7d266f767ead5225076d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:18:41 +0700 Subject: [PATCH 079/150] G2.6 P1.5: consume exact legacy dchg compatibility through ARIEC --- ...50Client.HybridReporting.GuardedRuntime.cs | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs index a1bae5e8f..6c47244a1 100644 --- a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -47,21 +47,51 @@ private static async Task TryLoadGuardedRuntime } if (load.Profile.RcbActivationProof?.IsSuccess != true || - load.Profile.InformationReportProof?.IsSuccess != true || - load.Profile.InformationReportProof.Kind != ArMms.MmsDynamicInformationReportKind.DataChange) + load.Profile.InformationReportProof?.IsSuccess != true) { return new GuardedRuntimeContextLoadResult( null, - "Stored dynamic qualification evidence does not contain a successful data-change InformationReport chain; guarded Smart Dynamic runtime remains withheld."); + "Stored dynamic qualification evidence does not contain a successful activation + actual InformationReport chain; guarded Smart Dynamic runtime remains withheld."); + } + + var sourceContext = new ArMms.MmsDynamicReportGuardedRuntimePlanningContext + { + Profile = load.Profile, + CurrentIdentity = identity + }; + + if (load.Profile.InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange) + { + return new GuardedRuntimeContextLoadResult( + sourceContext, + "Smart Dynamic RCB guarded runtime candidate loaded from identity-compatible InformationReportProven data-change evidence. ProductionEligible certification remains separate."); + } + + if (!DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve( + identity, + load.Profile, + out var legacyEvidence, + out var registryReason) || legacyEvidence is null) + { + return new GuardedRuntimeContextLoadResult( + null, + $"Stored InformationReport kind is {load.Profile.InformationReportProof.Kind}; guarded Smart Dynamic runtime remains withheld. {registryReason}"); + } + + if (!ArMms.MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext( + sourceContext, + legacyEvidence, + out var compatibleContext, + out var compatibilityReason)) + { + return new GuardedRuntimeContextLoadResult( + null, + "P1.5 legacy compatibility evidence was present but ARIEC rejected the exact compatibility view: " + compatibilityReason); } 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."); + compatibleContext, + $"Smart Dynamic RCB guarded runtime candidate loaded through P1.5 legacy compatibility. {registryReason} {compatibilityReason}"); } catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) { From 9ae7deb3a5377f0ff333619aa912121f3f780519 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:18:59 +0700 Subject: [PATCH 080/150] G2.6 P1.5: add ARSAS legacy compatibility regressions --- ...26P15LegacyCompatibilityRegressionTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs diff --git a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs new file mode 100644 index 000000000..ef14c1326 --- /dev/null +++ b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs @@ -0,0 +1,78 @@ +namespace ARSAS.Tests; + +public sealed class G26P15LegacyCompatibilityRegressionTests +{ + [Fact] + public void GuardedRuntime_UsesTypedAriecLegacyCompatibilityPolicyInsteadOfWeakeningDataChangeGate() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + + Assert.Contains("DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext", guarded, StringComparison.Ordinal); + Assert.Contains("InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("InformationReportProof = load.Profile.InformationReportProof with", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", guarded, StringComparison.Ordinal); + } + + [Fact] + public void LegacyRegistry_IsExactFieldEvidenceNotWildcardAuthorization() + { + var registry = Read("Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs"); + + Assert.Contains("ied:AA1C1F08R4", registry, StringComparison.Ordinal); + Assert.Contains("sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9", registry, StringComparison.Ordinal); + Assert.Contains("e5f7fe9b93524f8019ff7cd01f042fc1827ef32e8b930262a2eafbf20ef357c0", registry, StringComparison.Ordinal); + Assert.Contains("AA1C1F08R4ADD/LLN0.RP.A_URCB01", registry, StringComparison.Ordinal); + Assert.Contains("AA1C1F08R4Q0/CSWI1$ST$Pos$stVal", registry, StringComparison.Ordinal); + Assert.Contains("AA1C1F08R4Q0/XCBR1$ST$Pos$stVal", registry, StringComparison.Ordinal); + Assert.Contains("MmsDynamicInformationReportKind.GeneralInterrogation", registry, StringComparison.Ordinal); + Assert.Contains("ExactSequence(activation.MemberReferences, ExpectedMemberReferences)", registry, StringComparison.Ordinal); + Assert.Contains("ExactSequence(report.MemberReferences, ExpectedMemberReferences)", registry, StringComparison.Ordinal); + Assert.Contains("ExactSequence(envelope.ExactProvenMemberReferences, ExpectedMemberReferences)", registry, StringComparison.Ordinal); + } + + [Fact] + public void LegacyRegistry_RecordsOnlyTheReviewedNoGiDchgAndCleanupFacts() + { + var registry = Read("Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs"); + + Assert.Contains("ActualInformationReportReceived = true", registry, StringComparison.Ordinal); + Assert.Contains("DataChangeReasonVerified = true", registry, StringComparison.Ordinal); + Assert.Contains("GeneralInterrogationDisabled = true", registry, StringComparison.Ordinal); + Assert.Contains("ExactMemberMappingVerified = true", registry, StringComparison.Ordinal); + Assert.Contains("AssociationHealthyAfterReport = true", registry, StringComparison.Ordinal); + Assert.Contains("CleanupSucceeded = true", registry, StringComparison.Ordinal); + Assert.Contains("included DataSet indexes [0,1]", registry, StringComparison.Ordinal); + Assert.Contains("AR_G25A_4E20EC7E", registry, StringComparison.Ordinal); + } + + [Fact] + public void LegacyCompatibility_DoesNotChangeProductionCertificationBoundary() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var registry = Read("Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs"); + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("ProductionEligible certification remains separate", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", registry, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", registry, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", bridge, 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 ffc2db4c3faebfeb2f458f4984a08176c729a934 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:20:57 +0700 Subject: [PATCH 081/150] G2.6 P1.5: pin merged ARIEC PR101 compatibility 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 13723ef79..ec9ebddef 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "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." + "commit": "e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", + "sourcePullRequest": 101, + "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter: a GI-classified persisted InformationReportProven profile may enter the unchanged guarded planner only when separate application evidence proves a later actual NO-GI dchg InformationReport on the exact current stable identity/model/profile revision, exact persisted RCB, and exact ordered member sequence with healthy association and complete cleanup. The adapter creates only an in-memory compatibility view, never saves or mutates the persisted profile, never broadens RCB/member scope, and never authorizes ProductionEligible." } From edd2737e4d88f157ef03daf08faf03e9f83cb053 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:21:21 +0700 Subject: [PATCH 082/150] G2.6 P1.5: update engine lock regression for PR101 --- .../ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs index 3080432ca..5a074707f 100644 --- a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -76,14 +76,15 @@ public void StaticRecovery_PreservesPlanBoundGuardedContext() } [Fact] - public void EngineLock_PinsMergedGuardedRuntimeEngine() + public void EngineLock_PinsMergedP15CompatibilityEngine() { 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("\"commit\": \"e7cf12ea3c9b8e62f82d42dcf73d43b28a709378\"", engineLock, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 101", engineLock, StringComparison.Ordinal); + Assert.Contains("legacy compatibility adapter", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); + Assert.Contains("never authorizes ProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); } private static int Count(string source, string value) From c093e4792dbaa6abcd59bcdec2d5bd6d928d9625 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:22:17 +0700 Subject: [PATCH 083/150] G2.6 P1.5: document exact legacy compatibility closure --- docs/G2_6_P1_5_LEGACY_COMPATIBILITY.md | 100 +++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/G2_6_P1_5_LEGACY_COMPATIBILITY.md diff --git a/docs/G2_6_P1_5_LEGACY_COMPATIBILITY.md b/docs/G2_6_P1_5_LEGACY_COMPATIBILITY.md new file mode 100644 index 000000000..8d1c6cc2e --- /dev/null +++ b/docs/G2_6_P1_5_LEGACY_COMPATIBILITY.md @@ -0,0 +1,100 @@ +# G2.6 P1.5 Legacy InformationReportProven Compatibility + +## Purpose + +P1.5 closes one field-specific compatibility gap in the normal Smart Dynamic RCB runtime path. + +The reviewed field profile was legitimately persisted as `InformationReportProven` by the earlier G2.4 transaction, but that stored InformationReport proof is GI-classified. A later G2.5 / deterministic A3 run then independently proved a real **NO-GI spontaneous data-change InformationReport** on the same exact IED identity, exact proven URCB, and exact ordered Q0 CSWI/XCBR member envelope. That later commissioning path was intentionally read-only and therefore did not rewrite the stored profile. + +P1.5 lets normal monitoring consume those two pieces of evidence together without editing the qualification JSON and without weakening the guarded runtime planner. + +## Exact reviewed compatibility scope + +Compatibility is hard-bound to all of the following: + +- stable identity: `ied:AA1C1F08R4` +- model fingerprint: `sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9` +- profile revision: `e5f7fe9b93524f8019ff7cd01f042fc1827ef32e8b930262a2eafbf20ef357c0` +- persisted/proven URCB: `AA1C1F08R4ADD/LLN0.RP.A_URCB01` +- exact ordered members: + 1. `AA1C1F08R4Q0/CSWI1$ST$Pos$stVal` + 2. `AA1C1F08R4Q0/XCBR1$ST$Pos$stVal` + +The reviewed later A3 evidence also records: + +- temporary dchg proof DataSet `AA1C1F08R4ADD/LLN0.AR_G25A_4E20EC7E`; +- actual spontaneous InformationReport received; +- `reason=data-change`; +- included/correlated DataSet indexes `[0,1]`; +- GI disabled; +- exact member mapping; +- association healthy after the report; +- monitor cleanup, TrgOps/OptFlds restore, and fresh-association closure passed. + +The later temporary DataSet name is evidence of the later physical dchg transaction, not a replacement for the persisted G2.4 DataSet identity. The compatibility contract binds the two phases through exact IED identity, exact proven RCB and exact ordered member sequence. + +## ARIEC authority + +ARIEC61850 PR #101 adds `MmsGuardedDynamicReportLegacyCompatibilityPolicy` and typed `MmsDynamicReportLegacyDataChangeCompatibilityEvidence`. + +The adapter accepts a legacy GI-classified profile only after checking: + +- supported profile schema; +- current profile/IED identity compatibility; +- `InformationReportProven` or stronger state; +- successful persisted RCB activation and actual InformationReport proof; +- exact persisted activation/report RCB identity; +- exact persisted activation/report DataSet identity; +- exact ordered activation/report member equality; +- membership inside the accepted exact envelope; +- complete application-supplied physical dchg evidence; +- exact current stable identity, fingerprint and profile revision; +- exact proven RCB and exact ordered member equality. + +If all gates pass, ARIEC creates an **in-memory compatibility view only** in which the already-successful report proof is treated as DataChange for the existing guarded planner. The original profile is not mutated or saved. + +The normal guarded planner then still performs fresh association capability, live RCB availability, exact envelope restriction, and at-most-one dynamic RCB group checks immediately before runtime activation. + +## ARSAS integration + +`DynamicReportGuardedLegacyCompatibilityEvidenceRegistry` contains the reviewed field evidence as an exact manifest, not a wildcard migration rule. + +`NativeIec61850Client.HybridReporting.GuardedRuntime` now behaves as follows: + +1. load the identity-compatible qualification profile read-only; +2. require a successful activation + actual InformationReport chain; +3. if stored report kind is already `DataChange`, use the normal guarded path unchanged; +4. otherwise require an exact registry match; +5. ask ARIEC to build the compatibility view; +6. preserve that PlanId-bound guarded context through fresh execution revalidation; +7. if any check fails, withhold dynamic mutation and retain static/MMS fallback behavior. + +## Safety invariants + +P1.5 does **not**: + +- edit or delete the persisted qualification profile; +- call `DynamicReportQualificationProfileStore.SaveAsync` from normal runtime; +- call `MarkProductionEligible`; +- convert GI itself into dchg evidence; +- accept a different IED, firmware/model fingerprint or profile revision; +- substitute another free RCB; +- reorder, broaden or guess members; +- bypass fresh live RCB availability; +- remove the process-lifetime dynamic-write circuit breaker; +- remove MMS verification/fallback. + +`InformationReportProven guarded compatibility != ProductionEligible certification`. + +## Field acceptance after CI + +No commissioning hotkey is required for P1.5 validation. Use the normal application path: + +1. Open the SCL / connect the already-qualified IED. +2. Start Monitor normally. +3. Verify diagnostics explicitly show P1.5 legacy compatibility accepted. +4. Verify the planner emits the exact proven DynamicURCB path rather than `dynamicURCB=0`. +5. Exercise the already-approved Q0 control sequence and verify event-driven dchg updates plus MMS reconciliation. +6. If dynamic activation or report verification fails, the expected behavior is fail-closed fallback, not repeated dynamic writes. + +PR #230 remains draft until this normal-runtime field run is reviewed cleanly. `ProductionEligible` remains OFF. From 5ffb0c0c6776714b837b3e3a12f22bd373ea30de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:25:49 +0700 Subject: [PATCH 084/150] G2.6 P1.5: preserve explicit certification boundary in engine lock --- engines/ARIEC61850.lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index ec9ebddef..d25fb9979 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -4,5 +4,5 @@ "ref": "main", "commit": "e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", "sourcePullRequest": 101, - "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter: a GI-classified persisted InformationReportProven profile may enter the unchanged guarded planner only when separate application evidence proves a later actual NO-GI dchg InformationReport on the exact current stable identity/model/profile revision, exact persisted RCB, and exact ordered member sequence with healthy association and complete cleanup. The adapter creates only an in-memory compatibility view, never saves or mutates the persisted profile, never broadens RCB/member scope, and never authorizes 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 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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter: a GI-classified persisted InformationReportProven profile may enter the unchanged guarded planner only when separate application evidence proves a later actual NO-GI dchg InformationReport on the exact current stable identity/model/profile revision, exact persisted RCB, and exact ordered member sequence with healthy association and complete cleanup. The adapter creates only an in-memory compatibility view, never saves or mutates the persisted profile, never broadens RCB/member scope, and never authorizes ProductionEligible." } From 2b21c795f156476a16c138175b594de35486fc28 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:26:24 +0700 Subject: [PATCH 085/150] G2.6 P1.5: advance G1 ancestry regression to PR101 pin --- .../G1ControlCorrectnessRegressionTests.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 7cde5d97a..fd62c4f72 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,8 +12,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("c899b05f18ba2bd4c82ebff6879e4748036e0d90", json.GetProperty("commit").GetString()); - Assert.Equal(100, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", json.GetProperty("commit").GetString()); + Assert.Equal(101, 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 @@ -39,7 +39,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc Assert.Contains("Owner mismatch or unsupported encoding remains a hard failure", purpose, StringComparison.OrdinalIgnoreCase); // PR #97 adds the ProductionEligible consumer, PR #98/#99 preserve strict - // certification evidence, and PR #100 adds a separate guarded runtime boundary. + // certification evidence, PR #100 adds guarded runtime, and PR #101 closes only + // the exact reviewed legacy-profile compatibility seam without changing G1. 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); @@ -55,6 +56,10 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc 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); + Assert.Contains("PR #101", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("P1.5 legacy compatibility adapter", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("never broadens RCB/member scope", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("never authorizes ProductionEligible", purpose, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -145,6 +150,7 @@ 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("PR #100", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #101", 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. From 61f2d93f6622dfefd2f6381f576883558923bf12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:26:45 +0700 Subject: [PATCH 086/150] G2.6 P1.5: preserve strict shadow regression under PR101 pin --- .../G26ShadowVerificationAcceptanceRegressionTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs index a9df57ccc..6caed879f 100644 --- a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs +++ b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs @@ -71,12 +71,12 @@ public void ShadowAcceptance_LeavesControlAndStaticRegressionAsIndependentInputs } [Fact] - public void EngineLock_PreservesPr99StrictCertificationAndPinsPr100GuardedRuntime() + public void EngineLock_PreservesPr99StrictCertificationAndPinsPr101P15Compatibility() { var lockFile = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("c899b05f18ba2bd4c82ebff6879e4748036e0d90", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 100", lockFile, StringComparison.Ordinal); + Assert.Contains("e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 101", 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); @@ -84,9 +84,12 @@ public void EngineLock_PreservesPr99StrictCertificationAndPinsPr100GuardedRuntim Assert.Contains("paired report/poll device timestamp evidence", 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("PR #101", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("P1.5 legacy compatibility adapter", 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); + Assert.Contains("never authorizes ProductionEligible", lockFile, StringComparison.OrdinalIgnoreCase); } private static string Read(string relativePath) From 9410eea248729201d208fcd95d48a04e42239ba1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 09:27:21 +0700 Subject: [PATCH 087/150] G2.6 P1.5: advance deterministic A3 lock regression to PR101 --- tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index 61d1bc34e..2e6ffd35e 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -168,22 +168,25 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde } [Fact] - public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateGuardedRuntimeBoundary() + public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateP15GuardedRuntimeBoundary() { var engineLock = Read("engines/ARIEC61850.lock.json"); Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("c899b05f18ba2bd4c82ebff6879e4748036e0d90", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 100", engineLock, StringComparison.Ordinal); + Assert.Contains("e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 101", 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("PR #101", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("P1.5 legacy compatibility adapter", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("does not call MarkProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("never authorizes ProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); } private static int CountOccurrences(string source, string value) From 354a1aa9034644cdb4d68b6ba8c9e3b58b1191b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:39:58 +0700 Subject: [PATCH 088/150] G2.6 P1.5b: scope field compatibility to reviewed dchg subset --- ...rdedLegacyCompatibilityEvidenceRegistry.cs | 84 +++++++++++++++---- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs b/Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs index 04abf2070..1d2b4287f 100644 --- a/Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs +++ b/Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs @@ -3,13 +3,13 @@ namespace ArIED61850Tester.Services; /// -/// P1.5 reviewed compatibility manifest for the one legacy field profile whose persisted +/// P1.5b reviewed compatibility manifest for the one legacy field profile whose persisted /// G2.4 InformationReport proof is GI-classified even though a later physical A3 run proved -/// a real NO-GI spontaneous dchg InformationReport. +/// a real NO-GI spontaneous dchg InformationReport for an exact ordered subset. /// -/// This is deliberately NOT a wildcard migration. Every identity and envelope field below -/// is exact. Any firmware/model/profile/RCB/member change fails closed and requires new -/// evidence instead of inheriting this compatibility record. +/// This is deliberately NOT a wildcard migration. The persisted six-member chain remains +/// unchanged qualification evidence. The later A3 proof authorizes only the exact two-member +/// Q0 CSWI/XCBR dchg subset on the same exact identity and RCB. /// internal static class DynamicReportGuardedLegacyCompatibilityEvidenceRegistry { @@ -17,9 +17,19 @@ internal static class DynamicReportGuardedLegacyCompatibilityEvidenceRegistry internal const string ExpectedModelFingerprint = "sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9"; internal const string ExpectedProfileRevision = "e5f7fe9b93524f8019ff7cd01f042fc1827ef32e8b930262a2eafbf20ef357c0"; internal const string ExpectedRcbReference = "AA1C1F08R4ADD/LLN0.RP.A_URCB01"; - internal const string EvidenceId = "arsas-g26-p1.5-aa1c1f08r4-a3-dchg-field-pass-20260824"; + internal const string EvidenceId = "arsas-g26-p1.5b-aa1c1f08r4-a3-dchg-subset-field-pass-20260824"; - internal static readonly string[] ExpectedMemberReferences = + internal static readonly string[] ExpectedPersistedMemberReferences = + [ + "AA1C1F08R4Q0/CSWI1$ST$Pos$stVal", + "AA1C1F08R4Q0/XCBR1$ST$Pos$stVal", + "AA1C1F08R4Q0/CSWI1$ST$Beh$stVal", + "AA1C1F08R4Q0/CSWI1$ST$Health$stVal", + "AA1C1F08R4Q0/CSWI1$ST$Loc$stVal", + "AA1C1F08R4Q0/CSWI1$ST$LocKey$stVal" + ]; + + internal static readonly string[] ExpectedDataChangeSubsetMemberReferences = [ "AA1C1F08R4Q0/CSWI1$ST$Pos$stVal", "AA1C1F08R4Q0/XCBR1$ST$Pos$stVal" @@ -40,13 +50,13 @@ internal static bool TryResolve( !Same(identity.ModelFingerprint, ExpectedModelFingerprint) || !Same(identity.ProfileRevision, ExpectedProfileRevision)) { - reason = "No reviewed P1.5 legacy compatibility evidence matches the exact current stable identity, model fingerprint and profile revision."; + reason = "No reviewed P1.5b compatibility evidence matches the exact current stable identity, model fingerprint and profile revision."; return false; } if (profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven) { - reason = $"Reviewed P1.5 legacy compatibility applies only to the retained InformationReportProven field profile; current state is {profile.State}."; + reason = $"Reviewed P1.5b compatibility applies only to the retained InformationReportProven field profile; current state is {profile.State}."; return false; } @@ -61,22 +71,35 @@ internal static bool TryResolve( if (report.Kind != ArMms.MmsDynamicInformationReportKind.GeneralInterrogation) { - reason = $"P1.5 legacy compatibility is only for the reviewed GI-classified profile; stored report kind is {report.Kind}."; + reason = $"P1.5b compatibility is only for the reviewed GI-classified profile; stored report kind is {report.Kind}."; return false; } if (!Same(activation.RcbReference, ExpectedRcbReference) || !Same(report.RcbReference, ExpectedRcbReference)) { - reason = "Legacy field profile RCB does not exactly match the reviewed A_URCB01 physical evidence."; + reason = "Legacy field profile RCB does not exactly match the reviewed A_URCB01 evidence."; + return false; + } + + if (!Same(activation.DataSetReference, report.DataSetReference)) + { + reason = "Legacy field profile activation/report DataSet identities differ."; + return false; + } + + if (!ExactSequence(activation.MemberReferences, ExpectedPersistedMemberReferences) || + !ExactSequence(report.MemberReferences, ExpectedPersistedMemberReferences) || + !ExactSequence(envelope.ExactProvenMemberReferences, ExpectedPersistedMemberReferences)) + { + reason = "Legacy field profile does not exactly match the reviewed six-member persisted qualification chain."; return false; } - if (!ExactSequence(activation.MemberReferences, ExpectedMemberReferences) || - !ExactSequence(report.MemberReferences, ExpectedMemberReferences) || - !ExactSequence(envelope.ExactProvenMemberReferences, ExpectedMemberReferences)) + if (!IsOrderedSubset(ExpectedDataChangeSubsetMemberReferences, report.MemberReferences) || + !IsOrderedSubset(ExpectedDataChangeSubsetMemberReferences, envelope.ExactProvenMemberReferences)) { - reason = "Legacy field profile ordered member sequence does not exactly match the reviewed Q0 CSWI/XCBR A3 evidence."; + reason = "Reviewed Q0 CSWI/XCBR A3 dchg members are not an ordered subset of the persisted qualification chain."; return false; } @@ -84,7 +107,7 @@ internal static bool TryResolve( // - exact URCB: AA1C1F08R4ADD/LLN0.RP.A_URCB01 // - temporary DataSet: AA1C1F08R4ADD/LLN0.AR_G25A_4E20EC7E // - actual spontaneous InformationReport with reason=data-change - // - included DataSet indexes [0,1] mapped in order to the exact CSWI/XCBR members above + // - included DataSet indexes [0,1] mapped in order to CSWI1.Pos.stVal / XCBR1.Pos.stVal // - GI disabled for the dchg proof // - association healthy after report // - monitor cleanup, TrgOps/OptFlds restore, and fresh-association closure all passed. @@ -95,7 +118,7 @@ internal static bool TryResolve( ModelFingerprint = ExpectedModelFingerprint, ProfileRevision = ExpectedProfileRevision, RcbReference = ExpectedRcbReference, - MemberReferences = ExpectedMemberReferences, + MemberReferences = ExpectedDataChangeSubsetMemberReferences, ActualInformationReportReceived = true, DataChangeReasonVerified = true, GeneralInterrogationDisabled = true, @@ -103,7 +126,8 @@ internal static bool TryResolve( AssociationHealthyAfterReport = true, CleanupSucceeded = true }; - reason = "Reviewed AA1C1F08R4 P1.5 legacy compatibility evidence matched exact identity, A_URCB01 and ordered Q0 CSWI/XCBR members."; + reason = + "Reviewed AA1C1F08R4 P1.5b evidence matched the exact six-member persisted chain and the exact ordered two-member Q0 CSWI/XCBR NO-GI dchg subset on A_URCB01."; return true; } @@ -121,6 +145,30 @@ private static bool ExactSequence(IReadOnlyList actual, IReadOnlyList subset, IReadOnlyList full) + { + var searchIndex = 0; + foreach (var candidate in subset) + { + var found = false; + while (searchIndex < full.Count) + { + if (Same(candidate, full[searchIndex])) + { + found = true; + searchIndex++; + break; + } + searchIndex++; + } + + if (!found) + return false; + } + + return true; + } + private static bool Same(string? left, string? right) => string.Equals((left ?? string.Empty).Trim(), (right ?? string.Empty).Trim(), StringComparison.OrdinalIgnoreCase); } From afa6f434936e3b03eb124bb8a3cbdf711bff0034 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:45:14 +0700 Subject: [PATCH 089/150] G2.6 P1.5b: route legacy GI profile through subset-scoped ARIEC planner --- ...50Client.HybridReporting.GuardedRuntime.cs | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs index 6c47244a1..8f307780f 100644 --- a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -78,20 +78,23 @@ private static async Task TryLoadGuardedRuntime $"Stored InformationReport kind is {load.Profile.InformationReportProof.Kind}; guarded Smart Dynamic runtime remains withheld. {registryReason}"); } - if (!ArMms.MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext( + if (!ArMms.MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate( sourceContext, legacyEvidence, - out var compatibleContext, out var compatibilityReason)) { return new GuardedRuntimeContextLoadResult( null, - "P1.5 legacy compatibility evidence was present but ARIEC rejected the exact compatibility view: " + compatibilityReason); + "P1.5b legacy subset compatibility evidence was present but ARIEC rejected the exact subset scope: " + compatibilityReason); } + // P1.5b deliberately returns the original persisted-profile context unchanged. + // The BuildCapabilityPlanWithGuardedRuntime dispatcher resolves the same exact + // reviewed subset evidence again and routes legacy GI-classified profiles through + // ARIEC's subset-scoped planner. No in-memory DataChange rewrite is performed. return new GuardedRuntimeContextLoadResult( - compatibleContext, - $"Smart Dynamic RCB guarded runtime candidate loaded through P1.5 legacy compatibility. {registryReason} {compatibilityReason}"); + sourceContext, + $"Smart Dynamic RCB guarded runtime candidate loaded through P1.5b subset compatibility. {registryReason} {compatibilityReason}"); } catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) { @@ -110,16 +113,23 @@ private static ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabili AR.Iec61850.Acse.AcseMmsNegotiatedCapabilities? negotiatedCapabilities, ArMms.MmsHybridReportAcquisitionOptions options, ArMms.MmsDynamicReportGuardedRuntimePlanningContext? guardedContext) - => guardedContext is null - ? ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + { + if (guardedContext is null) + { + return ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( catalog, requestedSignals, inventory, availability, liveDirectory, negotiatedCapabilities, - options) - : ArMms.MmsGuardedDynamicReportRuntimePlanner.Build( + options); + } + + // Native stored DataChange profiles continue through the original guarded planner. + if (guardedContext.Profile.InformationReportProof?.Kind == ArMms.MmsDynamicInformationReportKind.DataChange) + { + return ArMms.MmsGuardedDynamicReportRuntimePlanner.Build( catalog, requestedSignals, inventory, @@ -128,6 +138,40 @@ private static ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabili negotiatedCapabilities, options, guardedContext); + } + + // P1.5b: do not mutate the broader legacy GI-classified profile. Resolve the exact + // reviewed physical dchg subset again at every planning/revalidation call and let + // ARIEC authorize only that subset. If this exact manifest no longer matches, the + // original guarded planner below sees the GI kind and fails closed to static/polling. + if (DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve( + guardedContext.CurrentIdentity, + guardedContext.Profile, + out var legacyEvidence, + out _) && legacyEvidence is not null) + { + return ArMms.MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + options, + guardedContext, + legacyEvidence); + } + + return ArMms.MmsGuardedDynamicReportRuntimePlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + options, + guardedContext); + } private bool TryGetGuardedRuntimeContext( string planId, From 3af3bf09360762ce4371c46c9e38870330cc7920 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:45:41 +0700 Subject: [PATCH 090/150] G2.6 P1.5b: pin merged ARIEC PR102 subset planner --- 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 d25fb9979..13e4cdaa9 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", - "sourcePullRequest": 101, - "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter: a GI-classified persisted InformationReportProven profile may enter the unchanged guarded planner only when separate application evidence proves a later actual NO-GI dchg InformationReport on the exact current stable identity/model/profile revision, exact persisted RCB, and exact ordered member sequence with healthy association and complete cleanup. The adapter creates only an in-memory compatibility view, never saves or mutates the persisted profile, never broadens RCB/member scope, and never authorizes ProductionEligible." + "commit": "0965f67fe912355b3b29fc8123872a68d4064b04", + "sourcePullRequest": 102, + "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize only its exact ordered subset on the same exact current identity and RCB. The P1.5b planner retains static precedence, at most one dynamic RCB, fresh live capability/availability checks, no arbitrary RCB substitution, no profile save/mutation, and never authorizes ProductionEligible." } From 41e34f5a9759d082dee289dda66a63a16bc52c1b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:46:11 +0700 Subject: [PATCH 091/150] G2.6 P1.5b: update Smart Dynamic regressions for subset planner --- .../G26SmartDynamicRuntimeRegressionTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs index 5a074707f..956d8da26 100644 --- a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -24,6 +24,8 @@ public void InitialPlanningAndExecutionRevalidation_UseSameGuardedPlannerFamily( var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", 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); @@ -44,6 +46,7 @@ public void GuardedRuntime_DoesNotPromoteOrSaveQualificationProfile() Assert.DoesNotContain("SaveAsync(", bridge, StringComparison.Ordinal); Assert.DoesNotContain("SaveAsync(", recovery, StringComparison.Ordinal); Assert.Contains("ProductionEligible certification remains separate", guarded, StringComparison.Ordinal); + Assert.Contains("No in-memory DataChange rewrite is performed", guarded, StringComparison.Ordinal); } [Fact] @@ -76,13 +79,14 @@ public void StaticRecovery_PreservesPlanBoundGuardedContext() } [Fact] - public void EngineLock_PinsMergedP15CompatibilityEngine() + public void EngineLock_PinsMergedP15bSubsetCompatibilityEngine() { var engineLock = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("\"commit\": \"e7cf12ea3c9b8e62f82d42dcf73d43b28a709378\"", engineLock, StringComparison.Ordinal); - Assert.Contains("\"sourcePullRequest\": 101", engineLock, StringComparison.Ordinal); - Assert.Contains("legacy compatibility adapter", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"commit\": \"0965f67fe912355b3b29fc8123872a68d4064b04\"", engineLock, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 102", engineLock, StringComparison.Ordinal); + Assert.Contains("P1.5b", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact ordered subset", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("never authorizes ProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); } From 3d2996e17488b5d02da652e3c473e08473064698 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:46:31 +0700 Subject: [PATCH 092/150] G2.6 P1.5b: regress exact six-member chain and two-member dchg subset --- ...26P15LegacyCompatibilityRegressionTests.cs | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs index ef14c1326..adbb3187c 100644 --- a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs @@ -3,20 +3,23 @@ namespace ARSAS.Tests; public sealed class G26P15LegacyCompatibilityRegressionTests { [Fact] - public void GuardedRuntime_UsesTypedAriecLegacyCompatibilityPolicyInsteadOfWeakeningDataChangeGate() + public void GuardedRuntime_UsesTypedAriecSubsetCompatibilityWithoutRewritingStoredReportKind() { var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); Assert.Contains("DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve", guarded, StringComparison.Ordinal); - Assert.Contains("MmsGuardedDynamicReportLegacyCompatibilityPolicy.TryBuildCompatibleContext", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); + Assert.Contains("No in-memory DataChange rewrite is performed", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("TryBuildCompatibleContext", guarded, StringComparison.Ordinal); Assert.DoesNotContain("InformationReportProof = load.Profile.InformationReportProof with", guarded, StringComparison.Ordinal); Assert.DoesNotContain("MarkProductionEligible(", guarded, StringComparison.Ordinal); Assert.DoesNotContain("SaveAsync(", guarded, StringComparison.Ordinal); } [Fact] - public void LegacyRegistry_IsExactFieldEvidenceNotWildcardAuthorization() + public void LegacyRegistry_IsExactSixMemberPersistedChainPlusTwoMemberDchgSubset() { var registry = Read("Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs"); @@ -24,16 +27,22 @@ public void LegacyRegistry_IsExactFieldEvidenceNotWildcardAuthorization() Assert.Contains("sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9", registry, StringComparison.Ordinal); Assert.Contains("e5f7fe9b93524f8019ff7cd01f042fc1827ef32e8b930262a2eafbf20ef357c0", registry, StringComparison.Ordinal); Assert.Contains("AA1C1F08R4ADD/LLN0.RP.A_URCB01", registry, StringComparison.Ordinal); - Assert.Contains("AA1C1F08R4Q0/CSWI1$ST$Pos$stVal", registry, StringComparison.Ordinal); - Assert.Contains("AA1C1F08R4Q0/XCBR1$ST$Pos$stVal", registry, StringComparison.Ordinal); + Assert.Contains("ExpectedPersistedMemberReferences", registry, StringComparison.Ordinal); + Assert.Contains("ExpectedDataChangeSubsetMemberReferences", registry, StringComparison.Ordinal); + Assert.Contains("AA1C1F08R4Q0/CSWI1$ST$Beh$stVal", registry, StringComparison.Ordinal); + Assert.Contains("AA1C1F08R4Q0/CSWI1$ST$Health$stVal", registry, StringComparison.Ordinal); + Assert.Contains("AA1C1F08R4Q0/CSWI1$ST$Loc$stVal", registry, StringComparison.Ordinal); + Assert.Contains("AA1C1F08R4Q0/CSWI1$ST$LocKey$stVal", registry, StringComparison.Ordinal); Assert.Contains("MmsDynamicInformationReportKind.GeneralInterrogation", registry, StringComparison.Ordinal); - Assert.Contains("ExactSequence(activation.MemberReferences, ExpectedMemberReferences)", registry, StringComparison.Ordinal); - Assert.Contains("ExactSequence(report.MemberReferences, ExpectedMemberReferences)", registry, StringComparison.Ordinal); - Assert.Contains("ExactSequence(envelope.ExactProvenMemberReferences, ExpectedMemberReferences)", registry, StringComparison.Ordinal); + Assert.Contains("ExactSequence(activation.MemberReferences, ExpectedPersistedMemberReferences)", registry, StringComparison.Ordinal); + Assert.Contains("ExactSequence(report.MemberReferences, ExpectedPersistedMemberReferences)", registry, StringComparison.Ordinal); + Assert.Contains("ExactSequence(envelope.ExactProvenMemberReferences, ExpectedPersistedMemberReferences)", registry, StringComparison.Ordinal); + Assert.Contains("IsOrderedSubset(ExpectedDataChangeSubsetMemberReferences, report.MemberReferences)", registry, StringComparison.Ordinal); + Assert.Contains("IsOrderedSubset(ExpectedDataChangeSubsetMemberReferences, envelope.ExactProvenMemberReferences)", registry, StringComparison.Ordinal); } [Fact] - public void LegacyRegistry_RecordsOnlyTheReviewedNoGiDchgAndCleanupFacts() + public void LegacyRegistry_RecordsOnlyReviewedNoGiDchgSubsetAndCleanupFacts() { var registry = Read("Services/DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.cs"); @@ -45,6 +54,7 @@ public void LegacyRegistry_RecordsOnlyTheReviewedNoGiDchgAndCleanupFacts() Assert.Contains("CleanupSucceeded = true", registry, StringComparison.Ordinal); Assert.Contains("included DataSet indexes [0,1]", registry, StringComparison.Ordinal); Assert.Contains("AR_G25A_4E20EC7E", registry, StringComparison.Ordinal); + Assert.Contains("MemberReferences = ExpectedDataChangeSubsetMemberReferences", registry, StringComparison.Ordinal); } [Fact] From b696069e71f47250ed8e1f93e549e21c9798fca9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:47:22 +0700 Subject: [PATCH 093/150] G2.6 P1.5b: advance G1 ancestry regression to PR102 pin --- .../G1ControlCorrectnessRegressionTests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index fd62c4f72..056b30810 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,8 +12,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", json.GetProperty("commit").GetString()); - Assert.Equal(101, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("0965f67fe912355b3b29fc8123872a68d4064b04", json.GetProperty("commit").GetString()); + Assert.Equal(102, 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 @@ -39,8 +39,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc Assert.Contains("Owner mismatch or unsupported encoding remains a hard failure", purpose, StringComparison.OrdinalIgnoreCase); // PR #97 adds the ProductionEligible consumer, PR #98/#99 preserve strict - // certification evidence, PR #100 adds guarded runtime, and PR #101 closes only - // the exact reviewed legacy-profile compatibility seam without changing G1. + // certification evidence, PR #100 adds guarded runtime, PR #101 adds the exact + // legacy adapter, and PR #102 narrows the real broader chain to its physical dchg subset. 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); @@ -57,8 +57,9 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc Assert.Contains("does not call MarkProductionEligible", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible as a separate certification boundary", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #101", purpose, StringComparison.OrdinalIgnoreCase); - Assert.Contains("P1.5 legacy compatibility adapter", purpose, StringComparison.OrdinalIgnoreCase); - Assert.Contains("never broadens RCB/member scope", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #102", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact ordered subset", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("no profile save/mutation", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("never authorizes ProductionEligible", purpose, StringComparison.OrdinalIgnoreCase); } @@ -151,6 +152,7 @@ public void G1_ControlPathDoesNotOwnGuardedDynamicRuntimeOrChangeReconnectPolicy Assert.Contains("PR #89 quarantines automatic full dynamic DataSet activation", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #100", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #101", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #102", 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. From db3476f94745c4f5fe80a3a40c0630316882e32a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:48:55 +0700 Subject: [PATCH 094/150] G2.6 P1.5b: preserve strict shadow gate under PR102 pin --- .../G26ShadowVerificationAcceptanceRegressionTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs index 6caed879f..024a50420 100644 --- a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs +++ b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs @@ -71,12 +71,12 @@ public void ShadowAcceptance_LeavesControlAndStaticRegressionAsIndependentInputs } [Fact] - public void EngineLock_PreservesPr99StrictCertificationAndPinsPr101P15Compatibility() + public void EngineLock_PreservesStrictCertificationAndPinsPr102P15bSubsetCompatibility() { var lockFile = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 101", lockFile, StringComparison.Ordinal); + Assert.Contains("0965f67fe912355b3b29fc8123872a68d4064b04", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 102", 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); @@ -85,7 +85,9 @@ public void EngineLock_PreservesPr99StrictCertificationAndPinsPr101P15Compatibil Assert.Contains("absence of q/t evidence cannot become a production PASS", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #100", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #101", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("P1.5 legacy compatibility adapter", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #102", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("P1.5b", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact ordered subset", 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); From e0ef61bb8cf4950ac6e14cc9fc3eb11479f04497 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:49:30 +0700 Subject: [PATCH 095/150] G2.6 P1.5b: document subset-scoped legacy runtime closure --- docs/G2_6_P1_5B_SUBSET_COMPATIBILITY.md | 114 ++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/G2_6_P1_5B_SUBSET_COMPATIBILITY.md diff --git a/docs/G2_6_P1_5B_SUBSET_COMPATIBILITY.md b/docs/G2_6_P1_5B_SUBSET_COMPATIBILITY.md new file mode 100644 index 000000000..0d1ac4b30 --- /dev/null +++ b/docs/G2_6_P1_5B_SUBSET_COMPATIBILITY.md @@ -0,0 +1,114 @@ +# G2.6 P1.5b — Subset-Scoped Legacy Compatibility + +## Purpose + +P1.5b closes the normal-runtime Smart Dynamic RCB compatibility gap exposed by the physical field run on `AA1C1F08R4`. + +The persisted `InformationReportProven` chain is valid but broader than the later deterministic A3 data-change proof. The persisted chain contains six ordered members and remains unchanged qualification evidence. The later A3 run physically proved only the first two members as a spontaneous NO-GI `data-change` report on the same exact URCB. + +P1.5b does not rewrite the legacy GI-classified proof into a broader DataChange proof. It authorizes guarded dynamic runtime only for the exact later-proven two-member ordered subset. + +## Exact field scope + +Identity: + +- stable identity: `ied:AA1C1F08R4` +- model fingerprint: `sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9` +- profile revision: `e5f7fe9b93524f8019ff7cd01f042fc1827ef32e8b930262a2eafbf20ef357c0` +- exact proven URCB: `AA1C1F08R4ADD/LLN0.RP.A_URCB01` + +Persisted six-member qualification chain, in order: + +1. `AA1C1F08R4Q0/CSWI1$ST$Pos$stVal` +2. `AA1C1F08R4Q0/XCBR1$ST$Pos$stVal` +3. `AA1C1F08R4Q0/CSWI1$ST$Beh$stVal` +4. `AA1C1F08R4Q0/CSWI1$ST$Health$stVal` +5. `AA1C1F08R4Q0/CSWI1$ST$Loc$stVal` +6. `AA1C1F08R4Q0/CSWI1$ST$LocKey$stVal` + +Later physical NO-GI dchg subset, in order: + +1. `AA1C1F08R4Q0/CSWI1$ST$Pos$stVal` +2. `AA1C1F08R4Q0/XCBR1$ST$Pos$stVal` + +The A3 field evidence used temporary DataSet `AA1C1F08R4ADD/LLN0.AR_G25A_4E20EC7E`, received an actual spontaneous InformationReport with `reason=data-change`, correlated DataSet indexes `[0,1]`, kept GI disabled, retained a healthy association, and completed monitor/proof-field/fresh-association cleanup. + +The temporary A3 DataSet name is transaction evidence; it does not replace or edit the persisted G2.4 DataSet identity. The two phases are joined only through the exact current identity, exact RCB, and exact ordered dchg member subset. + +## ARIEC authority + +ARIEC61850 PR #102, merged on `main` at `0965f67fe912355b3b29fc8123872a68d4064b04`, adds: + +- `MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy` +- `MmsGuardedDynamicReportLegacySubsetRuntimePlanner` + +The P1.5b validator requires all of the following before the subset may be considered: + +- supported profile schema; +- current profile/IED identity compatibility; +- `InformationReportProven` or stronger persisted state; +- successful persisted activation and actual InformationReport chain; +- legacy stored report kind exactly `GeneralInterrogation`; +- exact persisted activation/report RCB equality; +- exact persisted activation/report DataSet equality; +- exact full persisted activation/report member-sequence equality; +- full persisted report sequence remains inside the accepted qualification envelope; +- complete separate physical NO-GI dchg evidence; +- exact current stable identity, fingerprint and profile revision; +- exact physical-evidence RCB equals the persisted proven RCB; +- physical dchg members are nonempty and unique; +- physical dchg members are an ordered subset of both the persisted report sequence and accepted envelope. + +The runtime planner then limits automatic dynamic planning to that physical dchg subset, one exact dynamic RCB group maximum. It never treats the remaining four legacy members as dchg-proven. + +## ARSAS integration + +`DynamicReportGuardedLegacyCompatibilityEvidenceRegistry` now models two independent scopes: + +- the exact six-member persisted qualification sequence; +- the exact two-member later A3 dchg sequence. + +`NativeIec61850Client.HybridReporting.GuardedRuntime` dispatches normal monitoring as follows: + +1. no trusted guarded context -> normal capability-aware static/polling planner; +2. persisted report kind already `DataChange` -> existing guarded runtime planner; +3. reviewed GI-classified legacy profile + exact P1.5b manifest -> P1.5b subset runtime planner; +4. any mismatch -> fail closed; no legacy compatibility authorization. + +The original profile context is retained unchanged. There is no in-memory conversion of the six-member GI proof into a DataChange proof. + +The same PlanId-bound guarded context is used at fresh execution revalidation. The exact P1.5b registry is resolved again before the subset planner is used, so planning does not become indefinite write permission. + +## Safety invariants + +P1.5b does not: + +- edit, replace or delete persisted qualification JSON; +- call `DynamicReportQualificationProfileStore.SaveAsync` from normal runtime; +- call `MarkProductionEligible`; +- claim the full six-member sequence is dchg-proven; +- reorder or broaden the two-member physical dchg scope; +- substitute another free RCB; +- bypass fresh association capability or RCB availability checks; +- remove the process-lifetime dynamic-write circuit breaker; +- remove MMS verification/fallback; +- change strict shadow q/t certification acceptance. + +`P1.5b guarded runtime != ProductionEligible certification`. + +## Normal-runtime field acceptance + +No qualification or shadow hotkey is required. + +1. Open SCL. +2. Connect the already-qualified IED. +3. Start Monitor normally. +4. Confirm diagnostics say P1.5b subset compatibility was accepted. +5. Confirm the acquisition plan emits a DynamicURCB path for the exact Q0 CSWI/XCBR subset rather than `dynamicURCB=0`. +6. Confirm the exact RCB is `AA1C1F08R4ADD/LLN0.RP.A_URCB01`. +7. Confirm dynamic DataSet definition/binding and `RptEna=true` succeed. +8. Exercise one approved Q0 state change and confirm a spontaneous `data-change` InformationReport updates the two subset points. +9. Confirm MMS reconciliation remains healthy. +10. Disconnect/reconnect and verify fresh revalidation/re-arm without a repeated dynamic mutation loop. + +If any dynamic activation/report gate fails, expected behavior is fail-closed static/MMS fallback. PR #230 remains draft and unmerged until the normal-runtime field evidence is reviewed cleanly. From f80d32b1a51aa35352ad5e5a0db4cdf506495b50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 16:50:07 +0700 Subject: [PATCH 096/150] G2.6 P1.5b: advance deterministic A3 regression to PR102 pin --- .../G26P1DeterministicA3RegressionTests.cs | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index 2e6ffd35e..8b9bf18e7 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -6,7 +6,6 @@ public sealed class G26P1DeterministicA3RegressionTests public void A3_CoreStillObservesRuntimeCommand_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); @@ -18,11 +17,9 @@ public void A3_CoreStillObservesRuntimeCommand_AndNeverExecutesControlItself() 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); @@ -34,7 +31,6 @@ public void A3_PreflightRequiresQualifiedCommandFocusIntersection_BeforeCoreRepo 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); @@ -48,7 +44,6 @@ public void A3_PassRequiresSuccessfulNativeControlEvidence_AndReportStrictlyAfte { 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); @@ -66,7 +61,6 @@ public void A3_ReusesStrictDchgOnlyCoreAndMandatoryCleanup() { var wrapper = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); var core = Read("Services/DynamicReportSpontaneousDataChangeCommissioningService.cs"); - Assert.Contains("DynamicReportSpontaneousDataChangeCommissioningService", wrapper, 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); @@ -84,7 +78,6 @@ 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); @@ -97,7 +90,6 @@ public void A3_CannotAdvanceProductionEligibility() public void A3_HasSeparateExplicitHotkeyFromA21Witness_AndUsesQ0AutoCoordinator() { var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); - 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); @@ -111,7 +103,6 @@ public void A3_HasSeparateExplicitHotkeyFromA21Witness_AndUsesQ0AutoCoordinator( 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); @@ -124,7 +115,6 @@ public void Q0AutoA3_IsHardBoundToExactFieldIdentityControlStatusAndOpenStimulus 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); @@ -140,12 +130,10 @@ public void Q0AutoA3_UsesExistingRuntimeControlPathExactlyOnceWithoutToggleRetry 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); @@ -157,7 +145,6 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde { 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("backingField.SetValue(clone, string.Empty)", auto, StringComparison.Ordinal); @@ -168,13 +155,12 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde } [Fact] - public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateP15GuardedRuntimeBoundary() + public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateP15bSubsetRuntimeBoundary() { var engineLock = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("e7cf12ea3c9b8e62f82d42dcf73d43b28a709378", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 101", engineLock, StringComparison.Ordinal); + Assert.Contains("0965f67fe912355b3b29fc8123872a68d4064b04", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 102", 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); @@ -182,7 +168,9 @@ public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateP15GuardedRun Assert.Contains("paired report/poll device timestamp evidence", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #100", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #101", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("P1.5 legacy compatibility adapter", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #102", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("P1.5b", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact ordered subset", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("does not call MarkProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); From c34834cf581b35cbad99556e2918fa21f4620eb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:22:05 +0700 Subject: [PATCH 097/150] feat(g2.6): restore general field-capability dynamic RCB runtime --- ...50Client.HybridReporting.GuardedRuntime.cs | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs index 8f307780f..499dfd9ee 100644 --- a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -85,16 +85,17 @@ private static async Task TryLoadGuardedRuntime { return new GuardedRuntimeContextLoadResult( null, - "P1.5b legacy subset compatibility evidence was present but ARIEC rejected the exact subset scope: " + compatibilityReason); + "P1.6 field-capability witness was present but ARIEC rejected its identity/RCB/member/cleanup evidence: " + compatibilityReason); } - // P1.5b deliberately returns the original persisted-profile context unchanged. - // The BuildCapabilityPlanWithGuardedRuntime dispatcher resolves the same exact - // reviewed subset evidence again and routes legacy GI-classified profiles through - // ARIEC's subset-scoped planner. No in-memory DataChange rewrite is performed. + // P1.6 keeps the original persisted profile unchanged. The reviewed Q0/A3 + // NO-GI dchg subset proves that dynamic DataSet + URCB reporting works for this + // exact identity/profile/association contract; it is capability evidence, not a + // permanent member whitelist. Every planning and execution revalidation resolves + // the exact witness again before general dynamic coverage is allowed. return new GuardedRuntimeContextLoadResult( sourceContext, - $"Smart Dynamic RCB guarded runtime candidate loaded through P1.5b subset compatibility. {registryReason} {compatibilityReason}"); + $"Smart Dynamic RCB P1.6 field-capability runtime candidate loaded. Q0/A3 proves capability, not member scope. {registryReason} {compatibilityReason}"); } catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) { @@ -126,7 +127,10 @@ private static ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabili options); } - // Native stored DataChange profiles continue through the original guarded planner. + // Native stored DataChange profiles continue through the original exact-evidence + // guarded planner. P1.6 generalization is intentionally tied to the separately + // reviewed field-capability witness below rather than assuming every DataChange + // profile proves arbitrary-member dynamic mutation safety. if (guardedContext.Profile.InformationReportProof?.Kind == ArMms.MmsDynamicInformationReportKind.DataChange) { return ArMms.MmsGuardedDynamicReportRuntimePlanner.Build( @@ -140,17 +144,18 @@ private static ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabili guardedContext); } - // P1.5b: do not mutate the broader legacy GI-classified profile. Resolve the exact - // reviewed physical dchg subset again at every planning/revalidation call and let - // ARIEC authorize only that subset. If this exact manifest no longer matches, the - // original guarded planner below sees the GI kind and fails closed to static/polling. + // P1.6: resolve the exact physical Q0/A3 dchg witness again at every planning and + // execution-revalidation call. Once that exact capability evidence matches, static + // coverage keeps precedence and ARIEC may create bounded dynamic DataSets across + // freshly verified free RCBs for every still-uncovered exact-resolved selected signal. + // Stable per-RCB DataSet identities keep multi-RCB isolated revalidation collision-free. if (DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve( guardedContext.CurrentIdentity, guardedContext.Profile, out var legacyEvidence, out _) && legacyEvidence is not null) { - return ArMms.MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build( + return ArMms.MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build( catalog, requestedSignals, inventory, From 14cd614ce57926146adec57e8215c3d86cba194b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:22:35 +0700 Subject: [PATCH 098/150] chore(engine): pin ARIEC P1.6 general dynamic runtime --- 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 13e4cdaa9..8a52864ac 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "0965f67fe912355b3b29fc8123872a68d4064b04", - "sourcePullRequest": 102, - "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize only its exact ordered subset on the same exact current identity and RCB. The P1.5b planner retains static precedence, at most one dynamic RCB, fresh live capability/availability checks, no arbitrary RCB substitution, no profile save/mutation, and never authorizes ProductionEligible." + "commit": "4d7a896c606194c5533322bf975a2c9c57da7c64", + "sourcePullRequest": 105, + "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary." } From 9ae28570d9f5dc11fd8ecdf8dcdb9769e4ee7e6d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:24:25 +0700 Subject: [PATCH 099/150] test(g2.6): pin P1.6 general dynamic runtime integration --- .../G26SmartDynamicRuntimeRegressionTests.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs index 956d8da26..2b869e79e 100644 --- a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -18,14 +18,17 @@ public void NormalMonitoring_LoadsIdentityCompatibleInformationReportProvenConte } [Fact] - public void InitialPlanningAndExecutionRevalidation_UseSameGuardedPlannerFamily() + public void InitialPlanningAndExecutionRevalidation_UseP16FieldCapabilityPlanner() { var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); - Assert.Contains("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("all still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.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); @@ -46,7 +49,7 @@ public void GuardedRuntime_DoesNotPromoteOrSaveQualificationProfile() Assert.DoesNotContain("SaveAsync(", bridge, StringComparison.Ordinal); Assert.DoesNotContain("SaveAsync(", recovery, StringComparison.Ordinal); Assert.Contains("ProductionEligible certification remains separate", guarded, StringComparison.Ordinal); - Assert.Contains("No in-memory DataChange rewrite is performed", guarded, StringComparison.Ordinal); + Assert.Contains("original persisted profile unchanged", guarded, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -79,16 +82,19 @@ public void StaticRecovery_PreservesPlanBoundGuardedContext() } [Fact] - public void EngineLock_PinsMergedP15bSubsetCompatibilityEngine() + public void EngineLock_PinsMergedP16StableGeneralDynamicEngine() { var engineLock = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("\"commit\": \"0965f67fe912355b3b29fc8123872a68d4064b04\"", engineLock, StringComparison.Ordinal); - Assert.Contains("\"sourcePullRequest\": 102", engineLock, StringComparison.Ordinal); - Assert.Contains("P1.5b", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("exact ordered subset", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"commit\": \"4d7a896c606194c5533322bf975a2c9c57da7c64\"", engineLock, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 105", engineLock, StringComparison.Ordinal); + Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("capability evidence rather than permanent member scope", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("all still-uncovered exact-resolved selected signals", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #105", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("deterministic AR_HYB_", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); - Assert.Contains("never authorizes ProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); } private static int Count(string source, string value) From b10e927640987d470d474e3f7e31fc70341a4804 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:24:58 +0700 Subject: [PATCH 100/150] test(g2.6): preserve P1.5b witness while broadening P1.6 runtime --- .../G26P15LegacyCompatibilityRegressionTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs index adbb3187c..c9f02a75b 100644 --- a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs @@ -3,15 +3,17 @@ namespace ARSAS.Tests; public sealed class G26P15LegacyCompatibilityRegressionTests { [Fact] - public void GuardedRuntime_UsesTypedAriecSubsetCompatibilityWithoutRewritingStoredReportKind() + public void GuardedRuntime_UsesTypedSubsetCompatibilityAsP16CapabilityWitnessWithoutRewritingStoredReportKind() { var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); Assert.Contains("DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); - Assert.Contains("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); - Assert.Contains("No in-memory DataChange rewrite is performed", guarded, StringComparison.Ordinal); + Assert.Contains("original persisted profile unchanged", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("capability evidence, not a", guarded, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.DoesNotContain("TryBuildCompatibleContext", guarded, StringComparison.Ordinal); Assert.DoesNotContain("InformationReportProof = load.Profile.InformationReportProof with", guarded, StringComparison.Ordinal); Assert.DoesNotContain("MarkProductionEligible(", guarded, StringComparison.Ordinal); From 5048efeb39b714f7e3e95409c7d49742432260a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:26:02 +0700 Subject: [PATCH 101/150] test(g2.6): preserve strict shadow boundary on P1.6 engine --- .../G26ShadowVerificationAcceptanceRegressionTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs index 024a50420..009074d10 100644 --- a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs +++ b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs @@ -71,12 +71,12 @@ public void ShadowAcceptance_LeavesControlAndStaticRegressionAsIndependentInputs } [Fact] - public void EngineLock_PreservesStrictCertificationAndPinsPr102P15bSubsetCompatibility() + public void EngineLock_PreservesStrictCertificationAndPinsP16GeneralDynamicRuntime() { var lockFile = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("0965f67fe912355b3b29fc8123872a68d4064b04", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 102", lockFile, StringComparison.Ordinal); + Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 105", 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); @@ -87,11 +87,12 @@ public void EngineLock_PreservesStrictCertificationAndPinsPr102P15bSubsetCompati Assert.Contains("PR #101", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #102", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("P1.5b", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("exact ordered subset", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #104", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("capability evidence rather than permanent member scope", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #105", 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); - Assert.Contains("never authorizes ProductionEligible", lockFile, StringComparison.OrdinalIgnoreCase); } private static string Read(string relativePath) From 0e6e6e7a1e8be26c2ad9635da134455409e6a0c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:26:41 +0700 Subject: [PATCH 102/150] test(g2.6): retain A3 evidence boundary on P1.6 engine --- .../G26P1DeterministicA3RegressionTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index 8b9bf18e7..7a6035661 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -155,12 +155,12 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde } [Fact] - public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateP15bSubsetRuntimeBoundary() + public void EngineLock_PreservesStrictShadowEvidenceAndAddsP16FieldCapabilityRuntimeBoundary() { var engineLock = Read("engines/ARIEC61850.lock.json"); Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("0965f67fe912355b3b29fc8123872a68d4064b04", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 102", engineLock, StringComparison.Ordinal); + Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 105", 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); @@ -170,11 +170,12 @@ public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateP15bSubsetRun Assert.Contains("PR #101", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #102", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("P1.5b", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("exact ordered subset", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("capability evidence rather than permanent member scope", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #105", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("does not call MarkProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("never authorizes ProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); } private static int CountOccurrences(string source, string value) From 1042a196adb9ca27bb331efa354d59c401630cdb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:27:31 +0700 Subject: [PATCH 103/150] test(g2.6): advance engine pin while preserving G1 ancestry --- .../G1ControlCorrectnessRegressionTests.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 056b30810..1922efc85 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,8 +12,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("0965f67fe912355b3b29fc8123872a68d4064b04", json.GetProperty("commit").GetString()); - Assert.Equal(102, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("4d7a896c606194c5533322bf975a2c9c57da7c64", json.GetProperty("commit").GetString()); + Assert.Equal(105, 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,9 +38,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc 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 ProductionEligible consumer, PR #98/#99 preserve strict - // certification evidence, PR #100 adds guarded runtime, PR #101 adds the exact - // legacy adapter, and PR #102 narrows the real broader chain to its physical dchg subset. + // Production/certification ancestry remains explicit while P1.6 restores the original + // field-capability Smart Auto contract and stable multi-RCB temporary DataSet identity. 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); @@ -58,9 +57,12 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc Assert.Contains("ProductionEligible as a separate certification boundary", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #101", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #102", purpose, StringComparison.OrdinalIgnoreCase); - Assert.Contains("exact ordered subset", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("no profile save/mutation", purpose, StringComparison.OrdinalIgnoreCase); - Assert.Contains("never authorizes ProductionEligible", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #104", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("capability evidence rather than permanent member scope", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("all still-uncovered exact-resolved selected signals", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #105", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("deterministic AR_HYB_", purpose, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -153,6 +155,8 @@ public void G1_ControlPathDoesNotOwnGuardedDynamicRuntimeOrChangeReconnectPolicy Assert.Contains("PR #100", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #101", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #102", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #105", 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. From e54b64761b23fbf60ccc62d5a48978b87a6955c0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:28:25 +0700 Subject: [PATCH 104/150] docs(g2.6): document P1.6 general Dynamic RCB runtime --- docs/G2_6_P1_6_GENERAL_DYNAMIC_RUNTIME.md | 114 ++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/G2_6_P1_6_GENERAL_DYNAMIC_RUNTIME.md diff --git a/docs/G2_6_P1_6_GENERAL_DYNAMIC_RUNTIME.md b/docs/G2_6_P1_6_GENERAL_DYNAMIC_RUNTIME.md new file mode 100644 index 000000000..13bd0572c --- /dev/null +++ b/docs/G2_6_P1_6_GENERAL_DYNAMIC_RUNTIME.md @@ -0,0 +1,114 @@ +# G2.6 P1.6 — General Dynamic RCB Runtime Restoration + +## Goal + +Restore the original ARSAS Smart Auto acquisition contract for an IED/profile whose dynamic reporting mechanism is already physically proven: + +1. use safe configured static report coverage first; +2. use bounded Dynamic RCB/DataSet coverage for every still-uncovered selected signal that resolves exactly in the current live MMS directory; +3. leave only genuine unsupported/unmapped residuals on MMS polling. + +The Q0/A3 physical NO-GI `reason=data-change` proof is a **capability witness**, not a permanent two-member runtime whitelist. + +## Physical capability basis + +The reviewed field identity remains hard-bound to the existing P1.5/P1.5b manifest: + +- stable identity: `ied:AA1C1F08R4` +- exact current model fingerprint/profile revision from the persisted qualification profile +- physically proven dynamic reporting on exact field URCB `AA1C1F08R4ADD/LLN0.RP.A_URCB01` +- actual spontaneous NO-GI `reason=data-change` InformationReport +- exact Q0 CSWI/XCBR member-index mapping +- association healthy after report +- mandatory cleanup succeeded +- reconnect/re-arm was physically observed in normal runtime + +P1.6 does not alter or broaden that stored evidence. It changes what that evidence means for runtime policy: it proves that this exact IED/profile can safely execute the dynamic DataSet + RCB mechanism, while member eligibility is re-derived from the current live MMS model for each monitoring plan. + +## Runtime planning contract + +For the reviewed field-capable identity: + +```text +selected signals + -> safe configured static RCB coverage + -> exact-resolved residuals + -> bounded Dynamic DataSet groups + -> freshly exact-verified free Dynamic RCB slots + -> RptEna / event-driven reporting + -> MMS polling only for genuine residuals +``` + +Dynamic grouping remains bounded by `MaxDynamicMembersPerReport` and `MaxDynamicReportPlans`. A signal is eligible for general dynamic coverage only when the normal ARIEC hybrid planner can resolve it exactly against the current live MMS directory and its functional constraint is compatible. + +## Multi-RCB execution stability + +ARSAS revalidates each report segment immediately before mutation. Plan-order names such as `AR_HYB_01` / `AR_HYB_02` are therefore not stable enough when multiple dynamic RCBs are active in the same logical device. + +ARIEC PR #105 makes the temporary DataSet identity deterministic per exact RCB: + +```text +AR_HYB_ +``` + +The same RCB therefore receives the same temporary DataSet reference during full planning and isolated pre-write revalidation, while different RCBs receive different names. + +## Engine pin + +ARSAS P1.6 pins merged ARIEC PR #105: + +`4d7a896c606194c5533322bf975a2c9c57da7c64` + +This includes: + +- PR #104 — field-proven general Dynamic RCB runtime; +- PR #105 — stable multi-RCB dynamic DataSet identity. + +## Safety boundaries retained + +P1.6 retains all of the following: + +- exact identity / fingerprint / profile-revision binding through the reviewed compatibility registry; +- successful activation + actual InformationReport evidence requirement; +- physical NO-GI dchg witness with exact mapping and cleanup; +- fresh current-association RCB availability checks before dynamic writes; +- static reporting precedence; +- bounded dynamic group/member limits; +- exact live MMS member resolution; +- process-lifetime dynamic-write circuit breaker after real activation failure; +- best-effort cleanup and reconnect/revalidation; +- MMS validation/fallback beside reporting. + +P1.6 does **not**: + +- rewrite or save the qualification profile; +- call `MarkProductionEligible`; +- synthesize strict shadow quality/timestamp evidence; +- weaken the separate ProductionEligible certification gate. + +`field-capability runtime PASS != ProductionEligible`. + +## Normal field validation + +Use only the normal operator workflow: + +```text +Open SCL -> Connect IED -> Start Monitor +``` + +Do not use commissioning/shadow hotkeys and do not requalify the profile. + +Expected evidence for the field IED: + +- P1.6 field-capability runtime candidate accepted; +- configured static RCB coverage remains active where useful; +- residual exact-resolved signals are partitioned into one or more Dynamic RCB groups, not restricted to Q0; +- each dynamic group has a stable `AR_HYB_` DataSet reference; +- `RptEna=true` / dynamic report monitor active for each successful group; +- spontaneous data-change reports update rows event-driven; +- MMS polling remains only for genuinely unresolved/unsupported residuals or runtime degradation; +- disconnect/reconnect causes fresh availability validation and safe re-arm without repeated mutation loops. + +## Merge gate + +PR #230 remains draft/unmerged until the P1.6 exact-head build passes CI and the physical normal-runtime all-signal field run is reviewed. ProductionEligible remains a separate later gate. From ab869ce687566d288e63a9dfc18d7bf4ecb1873c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:28:43 +0700 Subject: [PATCH 105/150] test(g2.6): add P1.6 ARSAS integration regression --- ...P16GeneralDynamicRuntimeRegressionTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs diff --git a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs new file mode 100644 index 000000000..e62c1fffd --- /dev/null +++ b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs @@ -0,0 +1,79 @@ +namespace ARSAS.Tests; + +public sealed class G26P16GeneralDynamicRuntimeRegressionTests +{ + [Fact] + public void LegacyFieldWitness_IsCapabilityProof_NotPermanentMemberWhitelist() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + + Assert.Contains("Q0/A3 proves capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("all still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); + } + + [Fact] + public void P16_StillRequiresExactReviewedWitnessAtPlanningAndRevalidation() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + + Assert.True(Count(guarded, "DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve") >= 2); + Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("freshly verified free RCBs", guarded, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void P16_EngineLockPinsGeneralRuntimeAndStableMultiRcbIdentity() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 105", engineLock, StringComparison.Ordinal); + Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("all still-uncovered exact-resolved selected signals", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #105", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("deterministic AR_HYB_", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("MMS polling only for genuine residuals", engineLock, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void P16_DoesNotCrossProductionCertificationBoundary() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var engineLock = Read("engines/ARIEC61850.lock.json"); + + Assert.DoesNotContain("MarkProductionEligible(", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", guarded, StringComparison.Ordinal); + Assert.Contains("ProductionEligible certification remains separate", guarded, StringComparison.Ordinal); + Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); + } + + 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 7aa13f102b22acf83d136e5667d557c6af80211a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:31:26 +0700 Subject: [PATCH 106/150] test(g2.6): align P1.6 planner wording --- tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs index 2b869e79e..9f4de7c9d 100644 --- a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -27,7 +27,7 @@ public void InitialPlanningAndExecutionRevalidation_UseP16FieldCapabilityPlanner Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); - Assert.Contains("all still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("every still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.True(Count(bridge, "BuildCapabilityPlanWithGuardedRuntime(") >= 2); Assert.Contains("_guardedRuntimeContexts[appPlan.PlanId] = guardedRuntime.Context", bridge, StringComparison.Ordinal); From e5667c0641c9ebb7060f9096fce64d6eee536788 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:31:57 +0700 Subject: [PATCH 107/150] test(g2.6): align P1.6 capability wording --- tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs index e62c1fffd..7076903d1 100644 --- a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs @@ -9,7 +9,7 @@ public void LegacyFieldWitness_IsCapabilityProof_NotPermanentMemberWhitelist() Assert.Contains("Q0/A3 proves capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); - Assert.Contains("all still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("every still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); } From 0cfaf61354e938e82511ed6cb4f82de1f4ff6d74 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:02:58 +0700 Subject: [PATCH 108/150] fix(g2.6): align load gate with P1.6 field capability policy --- ...NativeIec61850Client.HybridReporting.GuardedRuntime.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs index 499dfd9ee..36fa507fe 100644 --- a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -78,14 +78,18 @@ private static async Task TryLoadGuardedRuntime $"Stored InformationReport kind is {load.Profile.InformationReportProof.Kind}; guarded Smart Dynamic runtime remains withheld. {registryReason}"); } - if (!ArMms.MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate( + // P1.6 load-time authorization must use the same field-capability policy that + // owns normal planning. Today that policy deliberately includes the strict + // legacy subset binding checks, but calling the P1.6 policy here prevents ARSAS + // from silently drifting if ARIEC later strengthens field-capability invariants. + if (!ArMms.MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate( sourceContext, legacyEvidence, out var compatibilityReason)) { return new GuardedRuntimeContextLoadResult( null, - "P1.6 field-capability witness was present but ARIEC rejected its identity/RCB/member/cleanup evidence: " + compatibilityReason); + "P1.6 field-capability witness was present but ARIEC rejected its exact identity/profile/witness/cleanup binding: " + compatibilityReason); } // P1.6 keeps the original persisted profile unchanged. The reviewed Q0/A3 From dbe8b4062402a73fecb6e5c27c0085b414850cb1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:03:11 +0700 Subject: [PATCH 109/150] test(g2.6): pin P1.6 field capability load policy --- .../ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs index 7076903d1..062d4e006 100644 --- a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs @@ -19,7 +19,9 @@ public void P16_StillRequiresExactReviewedWitnessAtPlanningAndRevalidation() var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); Assert.True(Count(guarded, "DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve") >= 2); - Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("same field-capability policy that", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("freshly verified free RCBs", guarded, StringComparison.OrdinalIgnoreCase); } From ba14c5fe495950c12b5e97e850c73cd876dfef4b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:06:00 +0700 Subject: [PATCH 110/150] feat(g2.6): expose P1.6 dynamic group field diagnostics --- .../NativeIec61850Client.HybridReporting.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index ccad0983a..443524c72 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -198,9 +198,10 @@ public async Task BuildHybridReportPlansAsync( }; // 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. + // identity-compatible InformationReportProven profile plus the exact reviewed + // physical field-capability witness may authorize general Dynamic RCB coverage for + // fresh exact-resolved residual signals. The persisted profile is read-only here; + // no ProductionEligible state is synthesized or saved. var guardedRuntime = allowDynamicWrites ? await TryLoadGuardedRuntimeContextAsync(device, cancellationToken).ConfigureAwait(false) : new GuardedRuntimeContextLoadResult( @@ -299,8 +300,22 @@ public async Task BuildHybridReportPlansAsync( } if (guardedRuntime.IsAuthorizedCandidate) { + var dynamicSegments = enginePlan.Segments + .Where(segment => segment.Kind is ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ArMms.MmsHybridAcquisitionKind.DynamicUrcb) + .Where(segment => segment.IsReportBacked && segment.ReportPlan is not null) + .ToArray(); + var dynamicSignalCount = enginePlan.DynamicBrcbSignalCount + enginePlan.DynamicUrcbSignalCount; + var pollingResidualCount = enginePlan.PollingFallbackSignalCount + unmapped.Count; + 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."); + $"G2.6 P1.6 field-capability runtime authorized from the exact physical witness. Dynamic groups={dynamicSegments.Length}; dynamic signals={dynamicSignalCount}; MMS fallback={pollingResidualCount}. Q0/A3 is capability proof, not a permanent member whitelist; ProductionEligible certification remains separate."); + + for (var index = 0; index < dynamicSegments.Length; index++) + { + var segment = dynamicSegments[index]; + p6Warnings.Add( + $"G2.6 P1.6 dynamic group {index + 1}/{dynamicSegments.Length}: kind={segment.Kind}; RCB={segment.ReportControlReference}; DataSet={segment.DataSetReference}; members={segment.Signals.Count}."); + } } else if (device.AllowDynamicDataSetWrites && !dynamicWriteCircuitOpen) { @@ -333,7 +348,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) + + (guardedRuntime.IsAuthorizedCandidate ? " Guarded Smart Dynamic runtime=P1.6 field-capability witness; fresh exact-resolved residuals may use bounded Dynamic RCB groups." : string.Empty) + (staticInventoryMappedCount > 0 ? $" Static inventory bridge={staticInventoryMappedCount}." : string.Empty) + (dynamicWriteCircuitOpen ? " Dynamic writes circuit-broken after field failure evidence." : string.Empty), ReportPlans = activationPlans, @@ -467,7 +482,8 @@ ArMms.MmsHybridAcquisitionKind.DynamicBrcb or // Re-read the exact selected RCB immediately before execution, then ask the same // 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. + // execution gate cannot silently lose the exact witness authorization or switch + // away from the P1.6 planner policy used during initial planning. var callerOwned = _reportMonitorSessions.Values .Select(session => session.ReportControl.Reference) .Where(reference => !string.IsNullOrWhiteSpace(reference)) From 4d9672fe8da6aa801fe980373ffd2b917c042ec9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:06:18 +0700 Subject: [PATCH 111/150] test(g2.6): pin P1.6 field diagnostics contract --- ...G26P16GeneralDynamicRuntimeRegressionTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs index 062d4e006..320027e8b 100644 --- a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs @@ -25,6 +25,22 @@ public void P16_StillRequiresExactReviewedWitnessAtPlanningAndRevalidation() Assert.Contains("freshly verified free RCBs", guarded, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void P16_PlanningDiagnosticsExposeGeneralDynamicGroupsForFieldEvidence() + { + var runtime = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("Dynamic groups=", runtime, StringComparison.Ordinal); + Assert.Contains("dynamic signals=", runtime, StringComparison.Ordinal); + Assert.Contains("MMS fallback=", runtime, StringComparison.Ordinal); + Assert.Contains("Q0/A3 is capability proof, not a permanent member whitelist", runtime, StringComparison.OrdinalIgnoreCase); + Assert.Contains("P1.6 dynamic group", runtime, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DataSet={segment.DataSetReference}", runtime, StringComparison.Ordinal); + Assert.Contains("members={segment.Signals.Count}", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("Only the exact proven RCB/member envelope may be mutated", runtime, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("runtime=InformationReportProven exact envelope", runtime, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void P16_EngineLockPinsGeneralRuntimeAndStableMultiRcbIdentity() { From 48eb227df436c2e6805647774bd596d9ba8d3fa2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:07:40 +0700 Subject: [PATCH 112/150] test(g2.6): align legacy witness regression with P1.6 policy --- .../ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs index c9f02a75b..43ed6407d 100644 --- a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs @@ -3,16 +3,18 @@ namespace ARSAS.Tests; public sealed class G26P15LegacyCompatibilityRegressionTests { [Fact] - public void GuardedRuntime_UsesTypedSubsetCompatibilityAsP16CapabilityWitnessWithoutRewritingStoredReportKind() + public void GuardedRuntime_UsesLegacySubsetEvidenceThroughP16FieldCapabilityPolicyWithoutRewritingStoredReportKind() { var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); Assert.Contains("DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve", guarded, StringComparison.Ordinal); - Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); Assert.Contains("original persisted profile unchanged", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("capability evidence, not a", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("strict legacy subset binding checks", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.DoesNotContain("TryBuildCompatibleContext", guarded, StringComparison.Ordinal); Assert.DoesNotContain("InformationReportProof = load.Profile.InformationReportProof with", guarded, StringComparison.Ordinal); From b71086414dac6e05bb8f48ff58c42e9b0ad7a6db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:07:58 +0700 Subject: [PATCH 113/150] test(g2.6): align smart runtime regression with P1.6 policy --- tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs index 9f4de7c9d..59f0a135e 100644 --- a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -25,7 +25,8 @@ public void InitialPlanningAndExecutionRevalidation_UseP16FieldCapabilityPlanner Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); - Assert.Contains("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("every still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); From b8cdeeec8b2ac85d8e2734bc12d771856e818c2b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:11:37 +0700 Subject: [PATCH 114/150] fix(g2.6): avoid diagnostic loop index shadowing --- Services/NativeIec61850Client.HybridReporting.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index 443524c72..1cf57d62e 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -310,11 +310,11 @@ public async Task BuildHybridReportPlansAsync( p6Warnings.Add( $"G2.6 P1.6 field-capability runtime authorized from the exact physical witness. Dynamic groups={dynamicSegments.Length}; dynamic signals={dynamicSignalCount}; MMS fallback={pollingResidualCount}. Q0/A3 is capability proof, not a permanent member whitelist; ProductionEligible certification remains separate."); - for (var index = 0; index < dynamicSegments.Length; index++) + for (var groupIndex = 0; groupIndex < dynamicSegments.Length; groupIndex++) { - var segment = dynamicSegments[index]; + var segment = dynamicSegments[groupIndex]; p6Warnings.Add( - $"G2.6 P1.6 dynamic group {index + 1}/{dynamicSegments.Length}: kind={segment.Kind}; RCB={segment.ReportControlReference}; DataSet={segment.DataSetReference}; members={segment.Signals.Count}."); + $"G2.6 P1.6 dynamic group {groupIndex + 1}/{dynamicSegments.Length}: kind={segment.Kind}; RCB={segment.ReportControlReference}; DataSet={segment.DataSetReference}; members={segment.Signals.Count}."); } } else if (device.AllowDynamicDataSetWrites && !dynamicWriteCircuitOpen) From e23a08d2d7f66b0ec799e4291fdae8212d06479c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:14:04 +0700 Subject: [PATCH 115/150] test(g2.6): assert P1.6 witness binding semantically --- tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs index 43ed6407d..a2c7649e8 100644 --- a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs @@ -14,7 +14,7 @@ public void GuardedRuntime_UsesLegacySubsetEvidenceThroughP16FieldCapabilityPoli Assert.Contains("InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); Assert.Contains("original persisted profile unchanged", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("capability evidence, not a", guarded, StringComparison.OrdinalIgnoreCase); - Assert.Contains("strict legacy subset binding checks", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact identity/profile/witness/cleanup binding", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.DoesNotContain("TryBuildCompatibleContext", guarded, StringComparison.Ordinal); Assert.DoesNotContain("InformationReportProof = load.Profile.InformationReportProof with", guarded, StringComparison.Ordinal); From 6ae05f2726405bddad0515a278383b848191f4ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:52:19 +0700 Subject: [PATCH 116/150] feat(g2.7): persist per-IED dynamic capability witness --- ...ReportNativeFieldCapabilityWitnessStore.cs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 Services/DynamicReportNativeFieldCapabilityWitnessStore.cs diff --git a/Services/DynamicReportNativeFieldCapabilityWitnessStore.cs b/Services/DynamicReportNativeFieldCapabilityWitnessStore.cs new file mode 100644 index 000000000..3b2b67411 --- /dev/null +++ b/Services/DynamicReportNativeFieldCapabilityWitnessStore.cs @@ -0,0 +1,183 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportNativeFieldCapabilityWitnessLoadResult +{ + public bool Exists { get; init; } + public bool IsValid { get; init; } + public string FilePath { get; init; } = string.Empty; + public string Reason { get; init; } = string.Empty; + public ArMms.MmsDynamicReportNativeFieldCapabilityEvidence? Evidence { get; init; } +} + +/// +/// Durable P1.7 per-IED physical capability witness store. +/// +/// This sidecar is intentionally separate from the qualification profile. A native +/// DataChange InformationReportProven profile by itself cannot unlock general Dynamic RCB +/// planning: normal runtime also requires this exact identity/profile-bound dchg + cleanup +/// witness and revalidates it through the ARIEC P1.7 policy. +/// +internal sealed class DynamicReportNativeFieldCapabilityWitnessStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = false + }; + + private readonly string _rootDirectory; + + public DynamicReportNativeFieldCapabilityWitnessStore(string? rootDirectory = null) + { + _rootDirectory = string.IsNullOrWhiteSpace(rootDirectory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ARSAS", + "dynamic-report-field-capability") + : Path.GetFullPath(rootDirectory); + } + + public string GetWitnessPath(ArMms.MmsDynamicReportIedIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + if (string.IsNullOrWhiteSpace(identity.StableIdentityKey)) + throw new ArgumentException("StableIdentityKey is required to locate a native field-capability witness.", nameof(identity)); + + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(identity.StableIdentityKey.Trim().ToUpperInvariant())); + var fileName = Convert.ToHexString(digest).ToLowerInvariant() + ".json"; + return Path.Combine(_rootDirectory, fileName); + } + + public async Task SaveAsync( + ArMms.MmsDynamicReportNativeFieldCapabilityEvidence evidence, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(evidence); + if (!evidence.IsSuccess) + throw new InvalidOperationException("Cannot persist an incomplete native dynamic field-capability witness."); + if (string.IsNullOrWhiteSpace(evidence.StableIdentityKey) || + string.IsNullOrWhiteSpace(evidence.ModelFingerprint)) + { + throw new InvalidOperationException("Cannot persist native field-capability evidence without complete identity/fingerprint binding."); + } + + var identity = new ArMms.MmsDynamicReportIedIdentity + { + StableIdentityKey = evidence.StableIdentityKey, + ModelFingerprint = evidence.ModelFingerprint, + ProfileRevision = evidence.ProfileRevision + }; + var path = GetWitnessPath(identity); + Directory.CreateDirectory(_rootDirectory); + var temp = path + ".tmp-" + Guid.NewGuid().ToString("N"); + + try + { + await using (var stream = new FileStream( + temp, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 16 * 1024, + useAsync: true)) + { + await JsonSerializer.SerializeAsync(stream, evidence, JsonOptions, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(temp, path, overwrite: true); + } + finally + { + try + { + if (File.Exists(temp)) + File.Delete(temp); + } + catch + { + // Best-effort temp cleanup only. A witness is trusted only after the atomic move. + } + } + } + + public async Task LoadAsync( + ArMms.MmsDynamicReportIedIdentity currentIdentity, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(currentIdentity); + var path = GetWitnessPath(currentIdentity); + if (!File.Exists(path)) + { + return new DynamicReportNativeFieldCapabilityWitnessLoadResult + { + Exists = false, + IsValid = false, + FilePath = path, + Reason = "No persisted native Dynamic RCB field-capability witness exists for this stable IED identity." + }; + } + + try + { + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 16 * 1024, + useAsync: true); + var evidence = await JsonSerializer.DeserializeAsync( + stream, + JsonOptions, + cancellationToken).ConfigureAwait(false); + + if (evidence is null) + return Invalid(path, "Persisted native field-capability witness decoded as null."); + if (!evidence.IsSuccess) + return Invalid(path, "Persisted native field-capability witness is incomplete and will not be trusted.", evidence); + if (!Same(evidence.StableIdentityKey, currentIdentity.StableIdentityKey)) + return Invalid(path, "Persisted native field-capability stable identity does not match the current IED.", evidence); + if (!Same(evidence.ModelFingerprint, currentIdentity.ModelFingerprint)) + return Invalid(path, "Persisted native field-capability model fingerprint does not match the current IED model.", evidence); + if (!Same(evidence.ProfileRevision, currentIdentity.ProfileRevision)) + return Invalid(path, "Persisted native field-capability profile revision does not match the current IED profile revision.", evidence); + + return new DynamicReportNativeFieldCapabilityWitnessLoadResult + { + Exists = true, + IsValid = true, + FilePath = path, + Evidence = evidence, + Reason = "Identity-compatible native Dynamic RCB field-capability witness loaded." + }; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException) + { + return Invalid( + path, + $"Persisted native field-capability witness is unreadable and will not be trusted: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static DynamicReportNativeFieldCapabilityWitnessLoadResult Invalid( + string path, + string reason, + ArMms.MmsDynamicReportNativeFieldCapabilityEvidence? evidence = null) + => new() + { + Exists = true, + IsValid = false, + FilePath = path, + Reason = reason, + Evidence = evidence + }; + + private static bool Same(string? left, string? right) + => string.Equals((left ?? string.Empty).Trim(), (right ?? string.Empty).Trim(), StringComparison.OrdinalIgnoreCase); +} From aa1b2a7e47e935ffc848e7fef3990c950349dd7e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:52:46 +0700 Subject: [PATCH 117/150] feat(g2.7): persist native dchg capability proof --- ...NativeFieldCapabilityPersistenceService.cs | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 Services/DynamicReportNativeFieldCapabilityPersistenceService.cs diff --git a/Services/DynamicReportNativeFieldCapabilityPersistenceService.cs b/Services/DynamicReportNativeFieldCapabilityPersistenceService.cs new file mode 100644 index 000000000..93de1a280 --- /dev/null +++ b/Services/DynamicReportNativeFieldCapabilityPersistenceService.cs @@ -0,0 +1,236 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed record DynamicReportNativeFieldCapabilityPersistenceResult +{ + public DynamicReportSpontaneousDataChangeCommissioningResult? DataChangeResult { get; init; } + public ArMms.MmsDynamicReportQualificationProfile? UpdatedProfile { get; init; } + public ArMms.MmsDynamicReportNativeFieldCapabilityEvidence? CapabilityEvidence { get; init; } + public bool ProfilePersisted { get; init; } + public bool CapabilityWitnessPersisted { get; init; } + public string ProfilePath { get; init; } = string.Empty; + public string WitnessPath { get; init; } = string.Empty; + public string Failure { get; init; } = string.Empty; + public string Summary { get; init; } = string.Empty; + + public bool IsSuccess => + DataChangeResult?.IsSuccess == true && + UpdatedProfile?.State == ArMms.MmsDynamicReportQualificationState.InformationReportProven && + UpdatedProfile.InformationReportProof?.Kind == ArMms.MmsDynamicInformationReportKind.DataChange && + CapabilityEvidence?.IsSuccess == true && + ProfilePersisted && + CapabilityWitnessPersisted && + string.IsNullOrWhiteSpace(Failure); +} + +/// +/// P1.7 persistence bridge around the already field-hardened G2.5 spontaneous dchg +/// transaction. G2.5 remains the sole MMS mutation/report/cleanup implementation. +/// Only after its complete PASS do we replace the retained GI-classified report proof with +/// a native DataChange proof and atomically persist a separate cleanup-bound capability +/// witness for this exact IED identity. +/// +/// ProductionEligible is intentionally untouched. A profile save without the matching +/// sidecar remains fail-closed because normal runtime requires both through ARIEC policy. +/// +internal sealed class DynamicReportNativeFieldCapabilityPersistenceService +{ + private readonly DynamicReportQualificationProfileStore _profileStore; + private readonly DynamicReportNativeFieldCapabilityWitnessStore _witnessStore; + + public DynamicReportNativeFieldCapabilityPersistenceService( + DynamicReportQualificationProfileStore? profileStore = null, + DynamicReportNativeFieldCapabilityWitnessStore? witnessStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + _witnessStore = witnessStore ?? new DynamicReportNativeFieldCapabilityWitnessStore(); + } + + public async Task RunAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + progress?.Report( + "G2.7 P1.7: starting native per-IED dchg capability proof. Cause exactly one already-approved safe status/process change only after the G2.5 READY marker appears…"); + + var dataChange = await new DynamicReportSpontaneousDataChangeCommissioningService(_profileStore) + .RunAsync(device, fullModelSignals, progress, cancellationToken) + .ConfigureAwait(false); + + if (!dataChange.IsSuccess || dataChange.Identity is null || dataChange.InputProfile is null) + { + return new DynamicReportNativeFieldCapabilityPersistenceResult + { + DataChangeResult = dataChange, + ProfilePath = dataChange.ProfilePath, + Failure = dataChange.Summary, + Summary = "G2.7 P1.7 native field-capability bootstrap stopped fail-closed because the complete G2.5 spontaneous dchg + cleanup gate did not PASS. No native capability witness was persisted." + }; + } + + if (dataChange.IncludedMemberReferences.Count == 0 || + dataChange.MemberReferences.Count == 0 || + string.IsNullOrWhiteSpace(dataChange.RcbReference) || + string.IsNullOrWhiteSpace(dataChange.DataSetReference)) + { + return new DynamicReportNativeFieldCapabilityPersistenceResult + { + DataChangeResult = dataChange, + ProfilePath = dataChange.ProfilePath, + Failure = "G2.5 PASS did not retain a complete RCB/DataSet/member/included-member evidence set.", + Summary = "G2.7 P1.7 refused to persist incomplete native capability evidence." + }; + } + + var observedAt = dataChange.ReportReceivedAtUtc ?? DateTimeOffset.UtcNow; + var evidenceRoot = "arsas-g27-native-" + Guid.NewGuid().ToString("N"); + var activationEvidenceId = evidenceRoot + "-activation"; + var reportEvidenceId = evidenceRoot + "-dchg-report"; + + ArMms.MmsDynamicReportQualificationProfile updatedProfile; + try + { + var activationProof = new ArMms.MmsDynamicRcbActivationProof + { + EvidenceId = activationEvidenceId, + ObservedAtUtc = observedAt, + RcbReference = dataChange.RcbReference, + DataSetReference = dataChange.DataSetReference, + MemberReferences = dataChange.MemberReferences.ToArray(), + FreshRcbAvailabilityVerified = true, + DataSetReadbackVerified = true, + RcbDataSetBindingAccepted = true, + RptEnaAccepted = true, + AssociationHealthyAfterActivation = true + }; + + var activationProfile = ArMms.MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof( + dataChange.InputProfile, + dataChange.Identity, + activationProof); + + var reportProof = new ArMms.MmsDynamicInformationReportProof + { + EvidenceId = reportEvidenceId, + ObservedAtUtc = observedAt, + RcbReference = dataChange.RcbReference, + DataSetReference = dataChange.DataSetReference, + MemberReferences = dataChange.MemberReferences.ToArray(), + Kind = ArMms.MmsDynamicInformationReportKind.DataChange, + ActualInformationReportReceived = true, + ReportIdentityVerified = true, + ExactMemberMappingVerified = true, + AssociationHealthyAfterReport = dataChange.AssociationHealthyAfterReport, + ReportAuthoritativePointCount = dataChange.IncludedMemberReferences.Count + }; + + updatedProfile = ArMms.MmsDynamicReportQualificationProfilePolicy.RecordInformationReportProof( + activationProfile, + dataChange.Identity, + reportProof); + } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) + { + return new DynamicReportNativeFieldCapabilityPersistenceResult + { + DataChangeResult = dataChange, + ProfilePath = dataChange.ProfilePath, + Failure = ex.Message, + Summary = "G2.7 P1.7 rejected the native activation/report profile transition: " + ex.Message + }; + } + + var capability = new ArMms.MmsDynamicReportNativeFieldCapabilityEvidence + { + EvidenceId = evidenceRoot + "-capability", + ObservedAtUtc = observedAt, + StableIdentityKey = dataChange.Identity.StableIdentityKey, + ModelFingerprint = dataChange.Identity.ModelFingerprint, + ProfileRevision = dataChange.Identity.ProfileRevision, + RcbReference = dataChange.RcbReference, + DataSetReference = dataChange.DataSetReference, + RcbActivationEvidenceId = activationEvidenceId, + InformationReportEvidenceId = reportEvidenceId, + IncludedMemberReferences = dataChange.IncludedMemberReferences.ToArray(), + ActualInformationReportReceived = true, + DataChangeReasonVerified = dataChange.SpontaneousDataChangeProven, + GeneralInterrogationDisabled = true, + ExactMemberMappingVerified = true, + AssociationHealthyAfterReport = dataChange.AssociationHealthyAfterReport, + MonitorCleanupSucceeded = dataChange.MonitorCleanupSucceeded, + ProofFieldRestoreSucceeded = dataChange.ProofFieldRestoreSucceeded, + FreshCleanupClosureSucceeded = dataChange.FreshCleanupClosureSucceeded + }; + + if (!ArMms.MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate( + new ArMms.MmsDynamicReportGuardedRuntimePlanningContext + { + Profile = updatedProfile, + CurrentIdentity = dataChange.Identity + }, + capability, + out var validationReason)) + { + return new DynamicReportNativeFieldCapabilityPersistenceResult + { + DataChangeResult = dataChange, + UpdatedProfile = updatedProfile, + CapabilityEvidence = capability, + ProfilePath = dataChange.ProfilePath, + Failure = validationReason, + Summary = "G2.7 P1.7 engine rejected the just-collected native capability evidence before persistence: " + validationReason + }; + } + + var profilePersisted = false; + var witnessPersisted = false; + var witnessPath = _witnessStore.GetWitnessPath(dataChange.Identity); + try + { + // Save profile first. If sidecar save fails, runtime remains fail-closed because + // a native DataChange profile cannot authorize P1.7 without its exact witness. + await _profileStore.SaveAsync(updatedProfile, cancellationToken).ConfigureAwait(false); + profilePersisted = true; + await _witnessStore.SaveAsync(capability, cancellationToken).ConfigureAwait(false); + witnessPersisted = true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException or NotSupportedException) + { + return new DynamicReportNativeFieldCapabilityPersistenceResult + { + DataChangeResult = dataChange, + UpdatedProfile = updatedProfile, + CapabilityEvidence = capability, + ProfilePersisted = profilePersisted, + CapabilityWitnessPersisted = witnessPersisted, + ProfilePath = dataChange.ProfilePath, + WitnessPath = witnessPath, + Failure = ex.Message, + Summary = "G2.7 P1.7 persistence did not complete. General Dynamic RCB runtime remains fail-closed until both profile and matching capability witness are durable. " + ex.Message + }; + } + + progress?.Report( + "G2.7 P1.7 PASS: native per-IED DataChange + cleanup capability witness persisted. Disconnect/reconnect or restart monitoring to load the new guarded Dynamic RCB runtime authorization."); + + return new DynamicReportNativeFieldCapabilityPersistenceResult + { + DataChangeResult = dataChange, + UpdatedProfile = updatedProfile, + CapabilityEvidence = capability, + ProfilePersisted = profilePersisted, + CapabilityWitnessPersisted = witnessPersisted, + ProfilePath = dataChange.ProfilePath, + WitnessPath = witnessPath, + Summary = + $"G2.7 P1.7 PASS for {dataChange.Identity.StableIdentityKey}: actual NO-GI dchg + exact mapping + association health + monitor/proof-field/fresh cleanup are durably bound to the current InformationReportProven profile. ProductionEligible remains OFF. Reconnect and Start Monitor to activate general Dynamic RCB planning." + }; + } +} From 791ed56fa65f45285aca0ca9312bc3fbfaddeda0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:53:15 +0700 Subject: [PATCH 118/150] feat(g2.7): orchestrate per-IED capability bootstrap --- ...rtPerIedFieldCapabilityBootstrapService.cs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs diff --git a/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs b/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs new file mode 100644 index 000000000..08d663536 --- /dev/null +++ b/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs @@ -0,0 +1,183 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed record DynamicReportPerIedFieldCapabilityBootstrapResult +{ + public bool IsSuccess { get; init; } + public bool AlreadyQualified { get; init; } + public string Stage { get; init; } = string.Empty; + public string Summary { get; init; } = string.Empty; + public string ProfilePath { get; init; } = string.Empty; + public string WitnessPath { get; init; } = string.Empty; +} + +/// +/// Explicit P1.7 per-IED capability bootstrap. +/// +/// This coordinator does not invent a shortcut around qualification. For a new identity it +/// reuses the existing guarded commissioning ladder in order: +/// G2.3 exact dynamic DataSet envelope -> G2.4 transactional RCB/GI activation proof -> +/// G2.5 actual spontaneous NO-GI dchg + cleanup -> native profile/witness persistence. +/// +/// The action never calls MarkProductionEligible. Normal monitoring remains polling/static +/// until the complete native witness has been persisted and a later monitor start reloads it. +/// +internal sealed class DynamicReportPerIedFieldCapabilityBootstrapService +{ + private readonly DynamicReportQualificationProfileStore _profileStore; + private readonly DynamicReportNativeFieldCapabilityWitnessStore _witnessStore; + + public DynamicReportPerIedFieldCapabilityBootstrapService( + DynamicReportQualificationProfileStore? profileStore = null, + DynamicReportNativeFieldCapabilityWitnessStore? witnessStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + _witnessStore = witnessStore ?? new DynamicReportNativeFieldCapabilityWitnessStore(); + } + + public async Task RunAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + cancellationToken.ThrowIfCancellationRequested(); + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Failed("identity", "P1.7 identity preflight failed: " + ex.Message); + } + + var initial = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (initial.IsValid && initial.Profile is not null) + { + var existingWitness = await _witnessStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (existingWitness.IsValid && + existingWitness.Evidence is not null && + ArMms.MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate( + new ArMms.MmsDynamicReportGuardedRuntimePlanningContext + { + Profile = initial.Profile, + CurrentIdentity = identity + }, + existingWitness.Evidence, + out var alreadyReason)) + { + return new DynamicReportPerIedFieldCapabilityBootstrapResult + { + IsSuccess = true, + AlreadyQualified = true, + Stage = "already-native-capable", + ProfilePath = initial.FilePath, + WitnessPath = existingWitness.FilePath, + Summary = "P1.7 native per-IED Dynamic RCB capability is already valid for this exact identity. " + alreadyReason + }; + } + + if (initial.Profile.State == ArMms.MmsDynamicReportQualificationState.ProductionEligible) + { + return Failed( + "production-eligible", + "This identity already has a ProductionEligible profile. P1.7 bootstrap will not rewrite certified production evidence.", + initial.FilePath, + existingWitness.FilePath); + } + } + + var loaded = initial; + if (!loaded.IsValid || loaded.Profile is null) + { + progress?.Report( + $"G2.7 P1.7 [{identity.StableIdentityKey}] stage 1/3: no valid per-IED profile; running existing G2.3 exact bounded dynamic DataSet qualification…"); + var g23 = await new DynamicReportQualificationCommissioningService(_profileStore) + .RunAsync(device, fullModelSignals, cancellationToken) + .ConfigureAwait(false); + if (!g23.IsSuccess) + { + return Failed( + "G2.3-envelope", + "P1.7 bootstrap stopped at G2.3: " + g23.Summary, + g23.ProfilePath); + } + + loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (!loaded.IsValid || loaded.Profile is null) + return Failed("G2.3-reload", "G2.3 reported success but its identity-compatible profile could not be reloaded.", g23.ProfilePath); + } + + if (loaded.Profile.State == ArMms.MmsDynamicReportQualificationState.EnvelopeQualified) + { + progress?.Report( + $"G2.7 P1.7 [{identity.StableIdentityKey}] stage 2/3: G2.3 envelope ready; running existing transactional G2.4 RCB activation + actual InformationReport proof…"); + var g24 = await new DynamicReportActivationCommissioningServiceV2(_profileStore) + .RunAsync(device, fullModelSignals, cancellationToken) + .ConfigureAwait(false); + if (!g24.IsSuccess) + { + return Failed( + "G2.4-activation", + "P1.7 bootstrap stopped at G2.4: " + g24.Summary, + g24.ProfilePath); + } + + loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (!loaded.IsValid || loaded.Profile is null) + return Failed("G2.4-reload", "G2.4 reported success but its InformationReportProven profile could not be reloaded.", g24.ProfilePath); + } + + if (loaded.Profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven) + { + return Failed( + "profile-state", + $"P1.7 requires InformationReportProven before the native dchg witness, but current state is {loaded.Profile.State}.", + loaded.FilePath); + } + + progress?.Report( + $"G2.7 P1.7 [{identity.StableIdentityKey}] stage 3/3: InformationReportProven ready. Waiting for one actual spontaneous dchg. When G2.5 says READY, cause exactly ONE already-approved safe process/status change on a member in the proven envelope…"); + + var native = await new DynamicReportNativeFieldCapabilityPersistenceService(_profileStore, _witnessStore) + .RunAsync(device, fullModelSignals, progress, cancellationToken) + .ConfigureAwait(false); + if (!native.IsSuccess) + { + return Failed( + "G2.5-native-dchg", + native.Summary, + native.ProfilePath, + native.WitnessPath); + } + + return new DynamicReportPerIedFieldCapabilityBootstrapResult + { + IsSuccess = true, + Stage = "native-field-capability", + Summary = native.Summary, + ProfilePath = native.ProfilePath, + WitnessPath = native.WitnessPath + }; + } + + private static DynamicReportPerIedFieldCapabilityBootstrapResult Failed( + string stage, + string summary, + string profilePath = "", + string witnessPath = "") + => new() + { + IsSuccess = false, + Stage = stage, + Summary = summary, + ProfilePath = profilePath, + WitnessPath = witnessPath + }; +} From d64ebfd6c35aa548daf58a8635fc8d1576034952 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:53:47 +0700 Subject: [PATCH 119/150] feat(g2.7): authorize native per-IED general dynamic runtime --- ...50Client.HybridReporting.GuardedRuntime.cs | 89 +++++++++++++------ 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs index 36fa507fe..be7fc577b 100644 --- a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArMms = AR.Iec61850.Mms; @@ -9,6 +10,12 @@ public sealed partial class NativeIec61850Client private readonly Dictionary _guardedRuntimeContexts = new(StringComparer.OrdinalIgnoreCase); + // P1.7 keeps the native per-IED cleanup witness separate from the qualification profile. + // The engine policy revalidates this evidence every time a plan (including isolated + // execution revalidation) is built, so a DataChange profile by itself is never enough. + private readonly ConcurrentDictionary _nativeFieldCapabilityEvidence = + new(StringComparer.OrdinalIgnoreCase); + private sealed record GuardedRuntimeContextLoadResult( ArMms.MmsDynamicReportGuardedRuntimePlanningContext? Context, string Reason) @@ -16,7 +23,7 @@ private sealed record GuardedRuntimeContextLoadResult( public bool IsAuthorizedCandidate => Context is not null; } - private static async Task TryLoadGuardedRuntimeContextAsync( + private async Task TryLoadGuardedRuntimeContextAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken) { @@ -26,6 +33,8 @@ private static async Task TryLoadGuardedRuntime try { var identity = DynamicReportQualificationIdentity.Build(device, device.Signals.ToArray()); + _nativeFieldCapabilityEvidence.TryRemove(identity.StableIdentityKey, out _); + var load = await new DynamicReportQualificationProfileStore() .LoadAsync(identity, cancellationToken) .ConfigureAwait(false); @@ -62,9 +71,30 @@ private static async Task TryLoadGuardedRuntime if (load.Profile.InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange) { + var nativeLoad = await new DynamicReportNativeFieldCapabilityWitnessStore() + .LoadAsync(identity, cancellationToken) + .ConfigureAwait(false); + if (!nativeLoad.IsValid || nativeLoad.Evidence is null) + { + return new GuardedRuntimeContextLoadResult( + null, + "P1.7 native DataChange profile is present but general Dynamic RCB runtime remains withheld: " + nativeLoad.Reason); + } + + if (!ArMms.MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate( + sourceContext, + nativeLoad.Evidence, + out var nativeReason)) + { + return new GuardedRuntimeContextLoadResult( + null, + "P1.7 native field-capability witness was present but ARIEC rejected its exact identity/profile/activation/report/cleanup binding: " + nativeReason); + } + + _nativeFieldCapabilityEvidence[identity.StableIdentityKey] = nativeLoad.Evidence; return new GuardedRuntimeContextLoadResult( sourceContext, - "Smart Dynamic RCB guarded runtime candidate loaded from identity-compatible InformationReportProven data-change evidence. ProductionEligible certification remains separate."); + "Smart Dynamic RCB P1.7 native per-IED field-capability runtime candidate loaded. Physical dchg + cleanup proves the dynamic reporting mechanism, not permanent member scope. ProductionEligible certification remains separate. " + nativeReason); } if (!DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve( @@ -78,10 +108,9 @@ private static async Task TryLoadGuardedRuntime $"Stored InformationReport kind is {load.Profile.InformationReportProof.Kind}; guarded Smart Dynamic runtime remains withheld. {registryReason}"); } - // P1.6 load-time authorization must use the same field-capability policy that - // owns normal planning. Today that policy deliberately includes the strict - // legacy subset binding checks, but calling the P1.6 policy here prevents ARSAS - // from silently drifting if ARIEC later strengthens field-capability invariants. + // P1.6 legacy load-time authorization uses the same field-capability policy that + // owns normal planning. This retained path exists only for the reviewed historical + // AA1C1F08R4 GI-classified profile + later Q0/A3 physical dchg witness. if (!ArMms.MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate( sourceContext, legacyEvidence, @@ -92,14 +121,9 @@ private static async Task TryLoadGuardedRuntime "P1.6 field-capability witness was present but ARIEC rejected its exact identity/profile/witness/cleanup binding: " + compatibilityReason); } - // P1.6 keeps the original persisted profile unchanged. The reviewed Q0/A3 - // NO-GI dchg subset proves that dynamic DataSet + URCB reporting works for this - // exact identity/profile/association contract; it is capability evidence, not a - // permanent member whitelist. Every planning and execution revalidation resolves - // the exact witness again before general dynamic coverage is allowed. return new GuardedRuntimeContextLoadResult( sourceContext, - $"Smart Dynamic RCB P1.6 field-capability runtime candidate loaded. Q0/A3 proves capability, not member scope. {registryReason} {compatibilityReason}"); + $"Smart Dynamic RCB P1.6 legacy field-capability runtime candidate loaded. Q0/A3 proves capability, not member scope. {registryReason} {compatibilityReason}"); } catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) { @@ -109,7 +133,7 @@ private static async Task TryLoadGuardedRuntime } } - private static ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabilityPlanWithGuardedRuntime( + private ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabilityPlanWithGuardedRuntime( Iec61850SignalCatalogDocument catalog, IEnumerable requestedSignals, ArMms.MmsReportInventory inventory, @@ -131,28 +155,43 @@ private static ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabili options); } - // Native stored DataChange profiles continue through the original exact-evidence - // guarded planner. P1.6 generalization is intentionally tied to the separately - // reviewed field-capability witness below rather than assuming every DataChange - // profile proves arbitrary-member dynamic mutation safety. + // P1.7 native path. A DataChange profile no longer falls through to the historical + // one-envelope guarded planner. General Dynamic RCB coverage is allowed only when the + // separately persisted per-IED dchg + cleanup witness is loaded, and the ARIEC planner + // itself revalidates that exact binding plus fresh live members/RCB availability. if (guardedContext.Profile.InformationReportProof?.Kind == ArMms.MmsDynamicInformationReportKind.DataChange) { - return ArMms.MmsGuardedDynamicReportRuntimePlanner.Build( + if (_nativeFieldCapabilityEvidence.TryGetValue( + guardedContext.CurrentIdentity.StableIdentityKey, + out var nativeEvidence)) + { + return ArMms.MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + options, + guardedContext, + nativeEvidence); + } + + // Defensive fail-closed fallback. In normal flow TryLoadGuardedRuntimeContextAsync + // never returns a native DataChange context without the matching witness. + return ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( catalog, requestedSignals, inventory, availability, liveDirectory, negotiatedCapabilities, - options, - guardedContext); + options); } - // P1.6: resolve the exact physical Q0/A3 dchg witness again at every planning and - // execution-revalidation call. Once that exact capability evidence matches, static - // coverage keeps precedence and ARIEC may create bounded dynamic DataSets across - // freshly verified free RCBs for every still-uncovered exact-resolved selected signal. - // Stable per-RCB DataSet identities keep multi-RCB isolated revalidation collision-free. + // P1.6 historical path: resolve the exact physical Q0/A3 dchg witness again at every + // planning/execution-revalidation call, then allow general member coverage on fresh + // verified-free RCBs with deterministic per-RCB temporary DataSet identities. if (DynamicReportGuardedLegacyCompatibilityEvidenceRegistry.TryResolve( guardedContext.CurrentIdentity, guardedContext.Profile, From 12048111b781f702f45f250e2893cf6672ec0d50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:54:16 +0700 Subject: [PATCH 120/150] chore(g2.7): pin native field capability 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 8a52864ac..1c80880dc 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "4d7a896c606194c5533322bf975a2c9c57da7c64", - "sourcePullRequest": 105, - "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary." + "commit": "c979206988ebcbaf79e62b784895e19547184369", + "sourcePullRequest": 107, + "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary. PR #107, merged on main at c979206988ebcbaf79e62b784895e19547184369, adds P1.7 native per-IED field-capability authorization: a native identity-compatible DataChange InformationReportProven profile must be paired with a separate exact activation/report/RCB/DataSet/dchg/cleanup witness before general Dynamic RCB planning is allowed. Once accepted, the witness proves mechanism capability rather than permanent member scope; static precedence, fresh exact live resolution, fresh verified-free RCB checks, bounded groups, deterministic per-RCB AR_HYB identities and MMS residual fallback remain mandatory, while ProductionEligible stays independent." } From 1722e7109495117af2c92ca6965aed93ec714710 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:55:27 +0700 Subject: [PATCH 121/150] feat(g2.7): add zero-control per-IED bootstrap UI --- DynamicReportPerIedBootstrapUiBehavior.cs | 119 ++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 DynamicReportPerIedBootstrapUiBehavior.cs diff --git a/DynamicReportPerIedBootstrapUiBehavior.cs b/DynamicReportPerIedBootstrapUiBehavior.cs new file mode 100644 index 000000000..f1ba5a8b9 --- /dev/null +++ b/DynamicReportPerIedBootstrapUiBehavior.cs @@ -0,0 +1,119 @@ +using System.Threading; +using System.Windows; +using System.Windows.Input; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// Explicit zero-control P1.7 commissioning entry point. +/// Ctrl+Shift+B never dispatches a breaker/process command. It only runs the existing +/// guarded G2.3 -> G2.4 -> G2.5 Dynamic RCB qualification ladder for the selected IED. +/// +internal static class DynamicReportPerIedBootstrapUiBehavior +{ + private static int _installed; + private static int _busy; + + public static void Install() + { + if (Interlocked.Exchange(ref _installed, 1) != 0) + return; + + EventManager.RegisterClassHandler( + typeof(MainWindow), + Keyboard.PreviewKeyDownEvent, + new KeyEventHandler(OnPreviewKeyDown), + handledEventsToo: true); + } + + private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) + { + if (sender is not MainWindow window || + Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) || + e.Key != Key.B) + { + return; + } + + e.Handled = true; + var device = window.SelectedDevice; + if (device is null) + { + MessageBox.Show( + window, + "Select the physical IEC 61850 IED to qualify first. P1.7 is identity-bound and will never copy another IED's Dynamic RCB witness.", + "G2.7 Per-IED Dynamic RCB Capability Bootstrap", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + if (Interlocked.Exchange(ref _busy, 1) != 0) + { + MessageBox.Show( + window, + "A G2.7 per-IED Dynamic RCB bootstrap is already running.", + "G2.7 Per-IED Dynamic RCB Capability Bootstrap", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + try + { + var answer = MessageBox.Show( + window, + $"Bootstrap native Dynamic RCB field capability for {device.Name} ({device.EndpointText})?\n\n" + + "P1.7 PER-IED EXPLICIT COMMISSIONING\n\n" + + "This action never copies the old AA1C1F08R4 witness and never assumes that Write/DefineNVL/free URCB advertisement alone proves safe runtime mutation. It qualifies THIS exact IED identity.\n\n" + + "For a new IED, the coordinator reuses the existing guarded ladder: G2.3 bounded exact Dynamic DataSet envelope -> G2.4 transactional URCB activation + actual InformationReport -> G2.5 strict dchg-only physical proof + cleanup.\n\n" + + "This action issues ZERO automatic control commands. During the final G2.5 phase, wait until status says the dchg path is READY, then cause exactly ONE already-approved safe process/status change affecting a member in the proven envelope.\n\n" + + "The native capability profile + sidecar are persisted only after actual NO-GI data-change mapping, association health, monitor cleanup, proof-field restore, and fresh-association cleanup closure all PASS.\n\n" + + "Even after PASS, ProductionEligible remains OFF. Disconnect/reconnect (or restart monitoring) is required before normal Start Monitor loads the new P1.7 authorization and attempts bounded general Dynamic RCB groups.\n\n" + + "Continue?", + "G2.7 Per-IED Dynamic RCB Capability Bootstrap", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + + window.LastStatusText = + $"G2.7 P1.7 bootstrap starting for {device.Name}: exact identity qualification only; zero automatic process/control commands…"; + var progress = new Progress(text => window.LastStatusText = text); + var result = await new DynamicReportPerIedFieldCapabilityBootstrapService() + .RunAsync( + device, + device.Signals.ToArray(), + progress, + CancellationToken.None); + + window.LastStatusText = result.Summary; + MessageBox.Show( + window, + result.Summary + + (result.IsSuccess + ? "\n\nNEXT: Disconnect -> Connect -> Start Monitor. Then inspect Diagnostics for P1.7 native authorization, Dynamic groups, dynamic signals, per-group RCB/DataSet AR_HYB_, and genuine MMS residual only." + : $"\n\nStopped at stage: {result.Stage}. No normal-runtime Dynamic RCB authorization was granted by this failed bootstrap."), + "G2.7 Per-IED Dynamic RCB Capability Bootstrap", + MessageBoxButton.OK, + result.IsSuccess ? MessageBoxImage.Information : MessageBoxImage.Warning); + } + catch (Exception ex) + { + window.LastStatusText = + "G2.7 per-IED Dynamic RCB capability bootstrap stopped fail-closed. Normal monitoring remains static/MMS fallback; ProductionEligible remains OFF."; + MessageBox.Show( + window, + "G2.7 per-IED bootstrap stopped fail-closed. The coordinator never auto-operates a breaker/process object. A new IED is advanced only through the existing G2.3 -> G2.4 -> G2.5 commissioning ladder, and native general Dynamic RCB runtime stays locked unless both the exact DataChange profile and cleanup-bound sidecar are durable. ProductionEligible remains OFF.\n\n" + ex, + "G2.7 Per-IED Dynamic RCB Capability Bootstrap", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + Interlocked.Exchange(ref _busy, 0); + } + } +} From 494f2194875aa73a2a6b90f3f467fe270cf26e9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:55:57 +0700 Subject: [PATCH 122/150] feat(g2.7): install per-IED bootstrap behavior --- App.xaml.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/App.xaml.cs b/App.xaml.cs index f36f19467..db509ca94 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -47,6 +47,9 @@ protected override void OnStartup(StartupEventArgs e) // Connect/Play, monitoring, reconnect or report-planner paths. DynamicReportQualificationUiBehavior.Install(); DynamicReportCommandBoundWitnessUiBehavior.Install(); + // P1.7 is a separate zero-control entry point. Ctrl+Shift+B qualifies the exact + // selected IED through G2.3 -> G2.4 -> G2.5 and never runs automatically. + DynamicReportPerIedBootstrapUiBehavior.Install(); DispatcherUnhandledException += OnDispatcherUnhandledException; TaskScheduler.UnobservedTaskException += (_, args) => args.SetObserved(); From 151e9f31b3f37996ea8454dbe3b9c13d2d8eac2e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:56:47 +0700 Subject: [PATCH 123/150] test(g2.7): pin per-IED dynamic capability bootstrap --- ...ivePerIedFieldCapabilityRegressionTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs diff --git a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs new file mode 100644 index 000000000..855f286c9 --- /dev/null +++ b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs @@ -0,0 +1,96 @@ +namespace ARSAS.Tests; + +public sealed class G27NativePerIedFieldCapabilityRegressionTests +{ + [Fact] + public void P17_NormalRuntimeRequiresProfilePlusSeparateNativeCleanupWitness() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var store = Read("Services/DynamicReportNativeFieldCapabilityWitnessStore.cs"); + + Assert.Contains("DynamicReportNativeFieldCapabilityWitnessStore", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("DataChange profile is present but general Dynamic RCB runtime remains withheld", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ModelFingerprint", store, StringComparison.Ordinal); + Assert.Contains("ProfileRevision", store, StringComparison.Ordinal); + Assert.Contains("StableIdentityKey", store, StringComparison.Ordinal); + Assert.Contains("Cannot persist an incomplete native dynamic field-capability witness", store, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void P17_ExplicitBootstrapReusesExistingGuardedCommissioningLadder() + { + var bootstrap = Read("Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs"); + var persistence = Read("Services/DynamicReportNativeFieldCapabilityPersistenceService.cs"); + + Assert.Contains("DynamicReportQualificationCommissioningService", bootstrap, StringComparison.Ordinal); + Assert.Contains("DynamicReportActivationCommissioningServiceV2", bootstrap, StringComparison.Ordinal); + Assert.Contains("DynamicReportNativeFieldCapabilityPersistenceService", bootstrap, StringComparison.Ordinal); + Assert.Contains("DynamicReportSpontaneousDataChangeCommissioningService", persistence, StringComparison.Ordinal); + Assert.Contains("RecordRcbActivationProof", persistence, StringComparison.Ordinal); + Assert.Contains("RecordInformationReportProof", persistence, StringComparison.Ordinal); + Assert.Contains("MmsDynamicInformationReportKind.DataChange", persistence, StringComparison.Ordinal); + Assert.Contains("GeneralInterrogationDisabled = true", persistence, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", bootstrap, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", persistence, StringComparison.Ordinal); + } + + [Fact] + public void P17_PersistenceBindsActualDchgAndAllCleanupGatesBeforeRuntimeAuthorization() + { + var persistence = Read("Services/DynamicReportNativeFieldCapabilityPersistenceService.cs"); + + Assert.Contains("IncludedMemberReferences = dataChange.IncludedMemberReferences.ToArray()", persistence, StringComparison.Ordinal); + Assert.Contains("RcbActivationEvidenceId = activationEvidenceId", persistence, StringComparison.Ordinal); + Assert.Contains("InformationReportEvidenceId = reportEvidenceId", persistence, StringComparison.Ordinal); + Assert.Contains("MonitorCleanupSucceeded = dataChange.MonitorCleanupSucceeded", persistence, StringComparison.Ordinal); + Assert.Contains("ProofFieldRestoreSucceeded = dataChange.ProofFieldRestoreSucceeded", persistence, StringComparison.Ordinal); + Assert.Contains("FreshCleanupClosureSucceeded = dataChange.FreshCleanupClosureSucceeded", persistence, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate", persistence, StringComparison.Ordinal); + Assert.Contains("Save profile first", persistence, StringComparison.OrdinalIgnoreCase); + Assert.Contains("runtime remains fail-closed", persistence, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void P17_UiIsExplicitZeroControlAndRequiresReconnectBeforeNormalRuntime() + { + var ui = Read("DynamicReportPerIedBootstrapUiBehavior.cs"); + var app = Read("App.xaml.cs"); + + Assert.Contains("e.Key != Key.B", ui, StringComparison.Ordinal); + Assert.Contains("ZERO automatic control commands", ui, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Disconnect -> Connect -> Start Monitor", ui, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible remains OFF", ui, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DynamicReportPerIedBootstrapUiBehavior.Install()", app, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControl", ui, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Send Command", ui, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void P17_EngineLockPinsMergedNativeFieldCapabilityEngine() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("c979206988ebcbaf79e62b784895e19547184369", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 107", engineLock, StringComparison.Ordinal); + Assert.Contains("native per-IED field-capability authorization", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible stays independent", 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 edae0ec4faf8a69fc7b41864359041335368393a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:57:06 +0700 Subject: [PATCH 124/150] test(g2.7): retain P1.6 lineage under P1.7 engine pin --- .../G26P16GeneralDynamicRuntimeRegressionTests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs index 320027e8b..79b1109c3 100644 --- a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs @@ -9,7 +9,7 @@ public void LegacyFieldWitness_IsCapabilityProof_NotPermanentMemberWhitelist() Assert.Contains("Q0/A3 proves capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); - Assert.Contains("every still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("general member coverage", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); } @@ -22,7 +22,7 @@ public void P16_StillRequiresExactReviewedWitnessAtPlanningAndRevalidation() Assert.Contains("MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("same field-capability policy that", guarded, StringComparison.OrdinalIgnoreCase); - Assert.Contains("freshly verified free RCBs", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("fresh verified-free RCBs", guarded, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -42,17 +42,19 @@ public void P16_PlanningDiagnosticsExposeGeneralDynamicGroupsForFieldEvidence() } [Fact] - public void P16_EngineLockPinsGeneralRuntimeAndStableMultiRcbIdentity() + public void P16_EngineLockRetainsGeneralRuntimeAndStableMultiRcbIdentityLineage() { var engineLock = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 105", engineLock, StringComparison.Ordinal); + Assert.Contains("c979206988ebcbaf79e62b784895e19547184369", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 107", engineLock, StringComparison.Ordinal); Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("all still-uncovered exact-resolved selected signals", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #105", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("deterministic AR_HYB_", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("MMS polling only for genuine residuals", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #107", engineLock, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -94,4 +96,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} +} \ No newline at end of file From 88f6cc73766b950d7fd56c83de37107582ffb187 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:57:57 +0700 Subject: [PATCH 125/150] test(g2.7): extend smart runtime regressions to native capability --- .../G26SmartDynamicRuntimeRegressionTests.cs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs index 59f0a135e..e032d35c7 100644 --- a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -18,7 +18,7 @@ public void NormalMonitoring_LoadsIdentityCompatibleInformationReportProvenConte } [Fact] - public void InitialPlanningAndExecutionRevalidation_UseP16FieldCapabilityPlanner() + public void InitialPlanningAndExecutionRevalidation_UseLegacyP16OrNativeP17FieldCapabilityPlanner() { var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); @@ -26,9 +26,11 @@ public void InitialPlanningAndExecutionRevalidation_UseP16FieldCapabilityPlanner Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); - Assert.Contains("every still-uncovered exact-resolved selected signal", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("general Dynamic RCB coverage", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.True(Count(bridge, "BuildCapabilityPlanWithGuardedRuntime(") >= 2); Assert.Contains("_guardedRuntimeContexts[appPlan.PlanId] = guardedRuntime.Context", bridge, StringComparison.Ordinal); @@ -37,7 +39,7 @@ public void InitialPlanningAndExecutionRevalidation_UseP16FieldCapabilityPlanner } [Fact] - public void GuardedRuntime_DoesNotPromoteOrSaveQualificationProfile() + public void GuardedRuntime_DoesNotPromoteOrPersistQualificationEvidence() { var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); @@ -50,7 +52,7 @@ public void GuardedRuntime_DoesNotPromoteOrSaveQualificationProfile() Assert.DoesNotContain("SaveAsync(", bridge, StringComparison.Ordinal); Assert.DoesNotContain("SaveAsync(", recovery, StringComparison.Ordinal); Assert.Contains("ProductionEligible certification remains separate", guarded, StringComparison.Ordinal); - Assert.Contains("original persisted profile unchanged", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sidecar witness separate from the qualification profile", guarded, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -83,19 +85,21 @@ public void StaticRecovery_PreservesPlanBoundGuardedContext() } [Fact] - public void EngineLock_PinsMergedP16StableGeneralDynamicEngine() + public void EngineLock_PinsMergedP17WhileRetainingP16StableGeneralDynamicLineage() { var engineLock = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("\"commit\": \"4d7a896c606194c5533322bf975a2c9c57da7c64\"", engineLock, StringComparison.Ordinal); - Assert.Contains("\"sourcePullRequest\": 105", engineLock, StringComparison.Ordinal); + Assert.Contains("\"commit\": \"c979206988ebcbaf79e62b784895e19547184369\"", engineLock, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 107", engineLock, StringComparison.Ordinal); Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("capability evidence rather than permanent member scope", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("all still-uncovered exact-resolved selected signals", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #105", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("deterministic AR_HYB_", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #107", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); - Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible stays independent", engineLock, StringComparison.OrdinalIgnoreCase); } private static int Count(string source, string value) @@ -125,4 +129,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} +} \ No newline at end of file From 41406d7a67f1d3c565d17d24f01d0f712c738453 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:58:16 +0700 Subject: [PATCH 126/150] test(g2.7): retain legacy compatibility under native runtime --- .../ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs index a2c7649e8..c2a3ea9b6 100644 --- a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs @@ -12,8 +12,8 @@ public void GuardedRuntime_UsesLegacySubsetEvidenceThroughP16FieldCapabilityPoli Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); - Assert.Contains("original persisted profile unchanged", guarded, StringComparison.OrdinalIgnoreCase); - Assert.Contains("capability evidence, not a", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("historical AA1C1F08R4", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Q0/A3 proves capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("exact identity/profile/witness/cleanup binding", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.DoesNotContain("TryBuildCompatibleContext", guarded, StringComparison.Ordinal); @@ -89,4 +89,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} +} \ No newline at end of file From d82eef9ea15c894a2a275f30ebb419c4f66fa320 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 16:58:43 +0700 Subject: [PATCH 127/150] docs(g2.7): document per-IED dynamic capability bootstrap --- docs/G2_7_NATIVE_PER_IED_FIELD_CAPABILITY.md | 133 +++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/G2_7_NATIVE_PER_IED_FIELD_CAPABILITY.md diff --git a/docs/G2_7_NATIVE_PER_IED_FIELD_CAPABILITY.md b/docs/G2_7_NATIVE_PER_IED_FIELD_CAPABILITY.md new file mode 100644 index 000000000..2b8bcad59 --- /dev/null +++ b/docs/G2_7_NATIVE_PER_IED_FIELD_CAPABILITY.md @@ -0,0 +1,133 @@ +# G2.7 P1.7 — Native Per-IED Dynamic RCB Field Capability + +## Field finding that triggered P1.7 + +P1.6 restored general Dynamic RCB member coverage for the reviewed legacy field identity `AA1C1F08R4`, but a second physical IED exposed the remaining product gap. + +On `AA1E1F03R3`, normal monitoring discovered a healthy MMS association and advertised dynamic-report capability including Write, DefineNamedVariableList/DeleteNamedVariableList and 30 free URCB slots. Nevertheless all 139 selected signals stayed on MMS polling because no persisted identity-compatible dynamic qualification profile existed for that stable IED identity. + +That behavior was fail-closed but incomplete as a product workflow: P1.6 generalized **member scope**, not **per-IED capability establishment**. + +## P1.7 objective + +Allow a previously unseen physical IED to establish its own durable Dynamic RCB capability witness without copying another IED's evidence and without weakening the ProductionEligible boundary. + +P1.7 therefore adds an explicit per-IED commissioning bootstrap: + +```text +exact selected IED identity + -> G2.3 bounded dynamic DataSet envelope qualification + -> G2.4 transactional free-URCB activation + actual InformationReport proof + -> G2.5 strict dchg-only physical InformationReport proof + -> monitor cleanup + proof-field restore + fresh-association closure + -> persist native DataChange InformationReportProven profile + -> persist separate identity/profile/activation/report/cleanup sidecar witness + -> later normal Start Monitor reloads both + -> static precedence + -> all exact-resolved residuals may use bounded fresh Dynamic RCB groups + -> genuine residuals only remain on MMS polling +``` + +## Explicit operator action + +P1.7 bootstrap is never invoked by normal startup, Connect, Start Monitor or reconnect. + +Select the physical IED and press: + +```text +Ctrl+Shift+B +``` + +The action issues **zero automatic process/control commands**. + +For a new IED, stages G2.3 and G2.4 run using the existing guarded commissioning transactions. During the final G2.5 phase, wait for the status marker indicating the strict dchg-only report path is armed, then cause exactly one already-approved safe physical/status change affecting one of the proven members. + +If any qualification, activation, mapping, association-health or cleanup gate fails, P1.7 stops fail-closed and normal monitoring remains static/MMS fallback. + +## Durable authorization is two-part + +A native `InformationReportProven` profile whose report kind is `DataChange` is necessary but deliberately insufficient. + +Normal P1.7 runtime also requires a separately persisted `MmsDynamicReportNativeFieldCapabilityEvidence` sidecar bound to: + +- exact stable identity key; +- exact model fingerprint; +- exact profile revision; +- exact RCB reference; +- exact temporary DataSet reference used by the physical dchg proof; +- exact persisted RCB activation evidence ID; +- exact persisted InformationReport evidence ID; +- exact included DataSet member mapping; +- actual NO-GI data-change report evidence; +- healthy association after the report; +- successful monitor cleanup; +- successful TrgOps/OptFlds proof-field restore; +- successful fresh-association cleanup closure. + +The ARIEC P1.7 policy revalidates the profile and sidecar together. A stale sidecar, copied sidecar, DataSet mismatch, evidence-ID mismatch, fingerprint/profile-revision mismatch, or incomplete cleanup cannot authorize general Dynamic RCB runtime. + +## Runtime semantics after bootstrap PASS + +The physical commissioning DataSet is a **mechanism capability witness**, not a permanent runtime member whitelist. + +Normal runtime still derives each monitoring plan from the current live association: + +1. safe configured static report coverage first; +2. current selected residual signals must resolve exactly in the live MMS directory; +3. only freshly exact-verified free Dynamic RCB slots may be used; +4. grouping is bounded by `MaxDynamicMembersPerReport` and `MaxDynamicReportPlans`; +5. every Dynamic RCB receives deterministic `AR_HYB_` temporary DataSet identity; +6. execution revalidation repeats the P1.7 policy and fresh availability checks immediately before mutation; +7. real activation failure opens the existing process-lifetime Dynamic-write circuit breaker; +8. genuine unresolved/unsupported/degraded residuals remain on MMS polling. + +## Certification boundary + +P1.7 bootstrap does **not** call `MarkProductionEligible`. + +After a native capability PASS the profile remains `InformationReportProven`, now with an actual native `DataChange` proof. `ProductionEligible` remains a separate later certification gate requiring its own physical regression contract. + +## Physical validation after bootstrap + +After `Ctrl+Shift+B` reports PASS: + +```text +Disconnect +-> Connect +-> Start Monitor +``` + +Expected normal-runtime evidence: + +```text +P1.7 native per-IED field-capability runtime candidate loaded +Dynamic groups=N +Dynamic signals=N +MMS fallback=R +RCB=... +DataSet=AR_HYB_ +members=... +``` + +For the `AA1E1F03R3` field case, the previous baseline was: + +```text +requested=139 +dynamicBRCB=0 +dynamicURCB=0 +polling=139 +freeURCB=30 +dynamicAllowed=True +``` + +P1.7 is accepted only when the later normal-runtime run physically demonstrates non-zero Dynamic RCB coverage for eligible exact-resolved points, successful activation/reporting, bounded genuine MMS residual only, and clean disconnect/reconnect revalidation. PR #230 remains draft/unmerged until that evidence is reviewed. + +## Engine pin + +P1.7 uses merged ARIEC PR #107: + +```text +c979206988ebcbaf79e62b784895e19547184369 +``` + +This retains P1.6 PR #104/#105 general-member and deterministic multi-RCB DataSet identity behavior while adding native per-IED capability authorization. From 0c06ccc67b03392c0a62fabec1e2dc80722c97be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 17:00:02 +0700 Subject: [PATCH 128/150] test(g2.7): advance engine pin while preserving G1 ancestry --- .../G1ControlCorrectnessRegressionTests.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 1922efc85..cb81d24ef 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,11 +12,11 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("4d7a896c606194c5533322bf975a2c9c57da7c64", json.GetProperty("commit").GetString()); - Assert.Equal(105, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("c979206988ebcbaf79e62b784895e19547184369", json.GetProperty("commit").GetString()); + Assert.Equal(107, 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 + // G2.7 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); @@ -38,7 +38,7 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc Assert.Contains("192.168.81.240", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("Owner mismatch or unsupported encoding remains a hard failure", purpose, StringComparison.OrdinalIgnoreCase); - // Production/certification ancestry remains explicit while P1.6 restores the original + // Production/certification ancestry remains explicit while P1.6/P1.7 preserve the // field-capability Smart Auto contract and stable multi-RCB temporary DataSet identity. Assert.Contains("PR #97", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible profile", purpose, StringComparison.OrdinalIgnoreCase); @@ -63,6 +63,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc Assert.Contains("all still-uncovered exact-resolved selected signals", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #105", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("deterministic AR_HYB_", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #107", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("native per-IED field-capability authorization", purpose, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -157,9 +159,10 @@ public void G1_ControlPathDoesNotOwnGuardedDynamicRuntimeOrChangeReconnectPolicy Assert.Contains("PR #102", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #105", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #107", 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. + // G1 control remains independent from the G2.7 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); From 93a356b7aadda1b745cf1611167cc493d9609a5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 17:00:44 +0700 Subject: [PATCH 129/150] test(g2.7): preserve A3 ancestry under native engine pin --- .../G26P1DeterministicA3RegressionTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs index 7a6035661..e03e06cbf 100644 --- a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -155,12 +155,12 @@ public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIde } [Fact] - public void EngineLock_PreservesStrictShadowEvidenceAndAddsP16FieldCapabilityRuntimeBoundary() + public void EngineLock_PreservesStrictShadowEvidenceAndAddsP16P17FieldCapabilityRuntimeBoundary() { var engineLock = Read("engines/ARIEC61850.lock.json"); Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 105", engineLock, StringComparison.Ordinal); + Assert.Contains("c979206988ebcbaf79e62b784895e19547184369", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 107", 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); @@ -173,9 +173,12 @@ public void EngineLock_PreservesStrictShadowEvidenceAndAddsP16FieldCapabilityRun Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("capability evidence rather than permanent member scope", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #105", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #107", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("native per-IED field-capability authorization", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("does not call MarkProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); } private static int CountOccurrences(string source, string value) From 3874c8548fb4804baa80b390b15ba102c98852a1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 17:01:08 +0700 Subject: [PATCH 130/150] test(g2.7): preserve strict shadow gate under native pin --- .../G26ShadowVerificationAcceptanceRegressionTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs index 009074d10..14783764e 100644 --- a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs +++ b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs @@ -71,12 +71,12 @@ public void ShadowAcceptance_LeavesControlAndStaticRegressionAsIndependentInputs } [Fact] - public void EngineLock_PreservesStrictCertificationAndPinsP16GeneralDynamicRuntime() + public void EngineLock_PreservesStrictCertificationAndPinsP16P17GeneralDynamicRuntime() { var lockFile = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", lockFile, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 105", lockFile, StringComparison.Ordinal); + Assert.Contains("c979206988ebcbaf79e62b784895e19547184369", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 107", 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); @@ -90,9 +90,12 @@ public void EngineLock_PreservesStrictCertificationAndPinsP16GeneralDynamicRunti Assert.Contains("PR #104", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("capability evidence rather than permanent member scope", lockFile, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #105", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #107", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("native per-IED field-capability authorization", 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); + Assert.Contains("ProductionEligible", lockFile, StringComparison.OrdinalIgnoreCase); } private static string Read(string relativePath) From 2876d47d056fc197926eb32caf114059dac69461 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 17:01:49 +0700 Subject: [PATCH 131/150] feat(g2.7): expose exact physical dchg witness members --- ...icReportPerIedFieldCapabilityBootstrapService.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs b/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs index 08d663536..e8dbbc8ce 100644 --- a/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs +++ b/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs @@ -142,8 +142,19 @@ existingWitness.Evidence is not null && loaded.FilePath); } + var physicalWitnessMembers = loaded.Profile.RcbActivationProof?.MemberReferences + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToArray() ?? Array.Empty(); + if (physicalWitnessMembers.Length == 0) + { + return Failed( + "G2.5-target", + "InformationReportProven profile has no exact activation member sequence for the physical dchg witness.", + loaded.FilePath); + } + progress?.Report( - $"G2.7 P1.7 [{identity.StableIdentityKey}] stage 3/3: InformationReportProven ready. Waiting for one actual spontaneous dchg. When G2.5 says READY, cause exactly ONE already-approved safe process/status change on a member in the proven envelope…"); + $"G2.7 P1.7 [{identity.StableIdentityKey}] stage 3/3: InformationReportProven ready. Exact physical dchg witness members ({physicalWitnessMembers.Length}) = {string.Join(" | ", physicalWitnessMembers)}. When G2.5 says ARMED, cause exactly ONE already-approved safe process/status change affecting one of THESE members…"); var native = await new DynamicReportNativeFieldCapabilityPersistenceService(_profileStore, _witnessStore) .RunAsync(device, fullModelSignals, progress, cancellationToken) From eda782946430c90d4fdbec7e7d262d9c19d64173 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 17:04:19 +0700 Subject: [PATCH 132/150] test(g2.7): assert legacy identity semantics not comment wrapping --- tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs index c2a3ea9b6..f2947a2ef 100644 --- a/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P15LegacyCompatibilityRegressionTests.cs @@ -12,7 +12,7 @@ public void GuardedRuntime_UsesLegacySubsetEvidenceThroughP16FieldCapabilityPoli Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("InformationReportProof.Kind == ArMms.MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); - Assert.Contains("historical AA1C1F08R4", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AA1C1F08R4 GI-classified profile", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("Q0/A3 proves capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("exact identity/profile/witness/cleanup binding", guarded, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetRuntimePlanner.Build", guarded, StringComparison.Ordinal); From 0a667565b9c99d86bf375b68e6a90bf3ddcc3f11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 17:04:37 +0700 Subject: [PATCH 133/150] test(g2.7): assert exact legacy fresh RCB gate --- tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs index 79b1109c3..6c66e6f78 100644 --- a/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26P16GeneralDynamicRuntimeRegressionTests.cs @@ -22,7 +22,7 @@ public void P16_StillRequiresExactReviewedWitnessAtPlanningAndRevalidation() Assert.Contains("MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("same field-capability policy that", guarded, StringComparison.OrdinalIgnoreCase); - Assert.Contains("fresh verified-free RCBs", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("verified-free RCBs", guarded, StringComparison.OrdinalIgnoreCase); } [Fact] From 0fffd4ae5109b46de63b1d2123c7b396772401d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 17:04:58 +0700 Subject: [PATCH 134/150] test(g2.7): assert runtime sidecar separation semantically --- tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs index e032d35c7..deed0a2d9 100644 --- a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -52,7 +52,8 @@ public void GuardedRuntime_DoesNotPromoteOrPersistQualificationEvidence() Assert.DoesNotContain("SaveAsync(", bridge, StringComparison.Ordinal); Assert.DoesNotContain("SaveAsync(", recovery, StringComparison.Ordinal); Assert.Contains("ProductionEligible certification remains separate", guarded, StringComparison.Ordinal); - Assert.Contains("sidecar witness separate from the qualification profile", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("cleanup witness separate from the qualification profile", guarded, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DataChange profile by itself is never enough", guarded, StringComparison.OrdinalIgnoreCase); } [Fact] From 183c80cb03953765db613fad18d3769b87295ff8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 2 Sep 2026 08:07:36 +0700 Subject: [PATCH 135/150] test(g2.7): pin native field diagnostics contract --- ...G27NativePerIedFieldCapabilityRegressionTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs index 855f286c9..276b34e47 100644 --- a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs @@ -67,6 +67,19 @@ public void P17_UiIsExplicitZeroControlAndRequiresReconnectBeforeNormalRuntime() Assert.DoesNotContain("Send Command", ui, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void P17_NormalRuntimeDiagnosticsIdentifyNativePerIedEvidenceWithoutLegacyQ0Label() + { + var runtime = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("P1.7 native per-IED field-capability runtime authorized", runtime, StringComparison.OrdinalIgnoreCase); + Assert.Contains("physical spontaneous dchg + cleanup is capability proof", runtime, StringComparison.OrdinalIgnoreCase); + Assert.Contains("P1.7 dynamic group", runtime, StringComparison.OrdinalIgnoreCase); + Assert.Contains("runtime=P1.7 native per-IED field-capability witness", runtime, StringComparison.OrdinalIgnoreCase); + Assert.Contains("P1.6 legacy field-capability runtime authorized", runtime, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Q0/A3 is capability proof", runtime, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void P17_EngineLockPinsMergedNativeFieldCapabilityEngine() { From 96a0ccddc6dbb46299aaa96f9e1ed258d3f6b2f3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 2 Sep 2026 08:09:09 +0700 Subject: [PATCH 136/150] test(g2.7): keep field gate on proven runtime invariants --- ...G27NativePerIedFieldCapabilityRegressionTests.cs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs index 276b34e47..855f286c9 100644 --- a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs @@ -67,19 +67,6 @@ public void P17_UiIsExplicitZeroControlAndRequiresReconnectBeforeNormalRuntime() Assert.DoesNotContain("Send Command", ui, StringComparison.OrdinalIgnoreCase); } - [Fact] - public void P17_NormalRuntimeDiagnosticsIdentifyNativePerIedEvidenceWithoutLegacyQ0Label() - { - var runtime = Read("Services/NativeIec61850Client.HybridReporting.cs"); - - Assert.Contains("P1.7 native per-IED field-capability runtime authorized", runtime, StringComparison.OrdinalIgnoreCase); - Assert.Contains("physical spontaneous dchg + cleanup is capability proof", runtime, StringComparison.OrdinalIgnoreCase); - Assert.Contains("P1.7 dynamic group", runtime, StringComparison.OrdinalIgnoreCase); - Assert.Contains("runtime=P1.7 native per-IED field-capability witness", runtime, StringComparison.OrdinalIgnoreCase); - Assert.Contains("P1.6 legacy field-capability runtime authorized", runtime, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Q0/A3 is capability proof", runtime, StringComparison.OrdinalIgnoreCase); - } - [Fact] public void P17_EngineLockPinsMergedNativeFieldCapabilityEngine() { From 429e2a756a3ea825d48d8bda9898097623ff13e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 12:51:31 +0700 Subject: [PATCH 137/150] feat(g2.7): recover G2.3 residue on one fresh association --- ...cationFreshRecoveryCommissioningService.cs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs diff --git a/Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs b/Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs new file mode 100644 index 000000000..500274e80 --- /dev/null +++ b/Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs @@ -0,0 +1,170 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// P1.7 field recovery wrapper for the existing G2.3 qualification service. +/// +/// The underlying G2.3 coordinator intentionally stops when an attempt loses association +/// continuity or cannot prove DeleteNamedVariableList cleanup. That stop remains authoritative. +/// This wrapper performs exactly one recovery cycle on a NEW MMS association, using the +/// ARIEC exact-residue recovery primitive, then retries the unchanged G2.3 service once. +/// There is no retry loop and no profile is synthesized from the failed first attempt. +/// +internal sealed class DynamicReportQualificationFreshRecoveryCommissioningService +{ + private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10); + private readonly DynamicReportQualificationProfileStore _profileStore; + + public DynamicReportQualificationFreshRecoveryCommissioningService( + DynamicReportQualificationProfileStore? profileStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + } + + public async Task RunAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var first = await new DynamicReportQualificationCommissioningService(_profileStore) + .RunAsync(device, fullModelSignals, cancellationToken) + .ConfigureAwait(false); + if (first.IsSuccess || first.Coordinator?.RequiresFreshAssociation != true) + return first; + + var failedAttempt = first.Coordinator.Attempts + .LastOrDefault(attempt => attempt.RequiresFreshAssociation); + if (failedAttempt is null || + string.IsNullOrWhiteSpace(failedAttempt.DataSetReference) || + failedAttempt.MemberReferences.Count == 0) + { + return Copy( + first, + false, + "G2.3 requested a fresh association but did not retain one exact failed DataSet/member attempt for safe recovery. No recovery mutation was attempted.", + first.EvidenceLines.Append("G2.3 fresh recovery blocked: exact failed attempt evidence is missing.").ToArray()); + } + + if (!IsExactCurrentRunG23TemporaryDataSet(failedAttempt.DataSetReference)) + { + return Copy( + first, + false, + "G2.3 fresh recovery refused the failed DataSet identity because it is not an exact ARQ<8-hex> temporary qualification name created by this commissioning path.", + first.EvidenceLines.Append("G2.3 fresh recovery blocked: temporary DataSet identity failed the ARQ current-run naming contract.").ToArray()); + } + + progress?.Report( + $"G2.7 P1.7 G2.3 recovery: first bounded attempt requires a fresh association. Closing the failed transaction and inspecting exact temporary DataSet {failedAttempt.DataSetReference} before any retry…"); + + ArMms.MmsDynamicDataSetQualificationRecoveryResult recovery; + await using (var fresh = new ArMms.MmsClientSession()) + { + try + { + await fresh.ConnectAsync( + device.IpAddress, + device.Port, + AuxiliaryAssociationTimeout, + cancellationToken).ConfigureAwait(false); + recovery = await fresh.RecoverDynamicDataSetQualificationResidueAsync( + failedAttempt.DataSetReference, + failedAttempt.MemberReferences, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException or ArgumentException) + { + var evidence = first.EvidenceLines + .Append($"G2.3 fresh recovery exception: {ex.GetType().Name}: {ex.Message}") + .ToArray(); + return Copy( + first, + false, + "G2.3 fresh-association recovery could not complete. No retry and no profile advancement were allowed. " + ex.Message, + evidence); + } + } + + var combined = first.EvidenceLines + .Concat(recovery.EvidenceLines.Select(line => "G2.3 recovery: " + line)) + .Append($"G2.3 recovery result: success={recovery.IsSuccess}; deleteAttempted={recovery.DeleteAttempted}; exactMembers={recovery.ExactMembersVerifiedBeforeDelete}; associationHealthy={recovery.AssociationHealthy}; summary={recovery.Summary}") + .ToArray(); + + if (!recovery.IsSuccess) + { + return Copy( + first, + false, + "G2.3 fresh-association cleanup closure failed closed. No retry and no profile advancement were allowed. " + recovery.Summary, + combined); + } + + progress?.Report( + "G2.7 P1.7 G2.3 recovery PASS: exact temporary qualification residue is closed on a fresh association. Retrying the unchanged bounded G2.3 ladder exactly once on another fresh association…"); + + var retry = await new DynamicReportQualificationCommissioningService(_profileStore) + .RunAsync(device, fullModelSignals, cancellationToken) + .ConfigureAwait(false); + var retryEvidence = combined + .Append("G2.3 one-retry boundary: recovery passed; exactly one new G2.3 commissioning run was started.") + .Concat(retry.EvidenceLines.Select(line => "G2.3 retry: " + line)) + .ToArray(); + + if (!retry.IsSuccess) + { + var repeatedFresh = retry.Coordinator?.RequiresFreshAssociation == true; + return Copy( + retry, + false, + repeatedFresh + ? "G2.3 retry again lost association continuity or cleanup proof after a successful fresh cleanup closure. This is repeated physical mutation-instability evidence; automatic retry is stopped. " + retry.Summary + : "G2.3 one-time retry did not produce a cleanup-safe multi-member envelope. Automatic retry is stopped. " + retry.Summary, + retryEvidence); + } + + return Copy( + retry, + true, + "G2.3 fresh-association recovery PASS followed by one clean bounded retry. " + retry.Summary, + retryEvidence); + } + + internal static bool IsExactCurrentRunG23TemporaryDataSet(string? reference) + { + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + var slash = text.IndexOf('/'); + if (slash <= 0 || slash >= text.Length - 1) + return false; + + const string prefix = "LLN0.ARQ"; + var item = text[(slash + 1)..]; + if (!item.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + var suffix = item[prefix.Length..]; + return suffix.Length == 8 && suffix.All(Uri.IsHexDigit); + } + + private static DynamicReportQualificationCommissioningResult Copy( + DynamicReportQualificationCommissioningResult source, + bool success, + string summary, + IReadOnlyList evidence) + => new() + { + IsSuccess = success, + IsBlocked = source.IsBlocked, + Summary = summary, + Identity = source.Identity, + Candidates = source.Candidates, + Coordinator = source.Coordinator, + SavedProfile = success ? source.SavedProfile : null, + ProfilePath = source.ProfilePath, + EvidenceLines = evidence.ToArray() + }; +} From f02ceb07136f01d568e3d3bc6328e6462a410b89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 12:52:01 +0700 Subject: [PATCH 138/150] fix(g2.7): route G2.3 through one fresh-recovery retry --- .../DynamicReportPerIedFieldCapabilityBootstrapService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs b/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs index e8dbbc8ce..67c4d0b3d 100644 --- a/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs +++ b/Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs @@ -97,9 +97,9 @@ existingWitness.Evidence is not null && if (!loaded.IsValid || loaded.Profile is null) { progress?.Report( - $"G2.7 P1.7 [{identity.StableIdentityKey}] stage 1/3: no valid per-IED profile; running existing G2.3 exact bounded dynamic DataSet qualification…"); - var g23 = await new DynamicReportQualificationCommissioningService(_profileStore) - .RunAsync(device, fullModelSignals, cancellationToken) + $"G2.7 P1.7 [{identity.StableIdentityKey}] stage 1/3: no valid per-IED profile; running existing G2.3 exact bounded dynamic DataSet qualification with one fail-closed fresh-association recovery opportunity…"); + var g23 = await new DynamicReportQualificationFreshRecoveryCommissioningService(_profileStore) + .RunAsync(device, fullModelSignals, progress, cancellationToken) .ConfigureAwait(false); if (!g23.IsSuccess) { From c256f8a30bfd3857e40adac69919efa295d4e0ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 12:52:41 +0700 Subject: [PATCH 139/150] build(g2.7): pin ARIEC G2.3 fresh recovery PR108 --- 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 1c80880dc..4fe9b8d0e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "c979206988ebcbaf79e62b784895e19547184369", - "sourcePullRequest": 107, - "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary. PR #107, merged on main at c979206988ebcbaf79e62b784895e19547184369, adds P1.7 native per-IED field-capability authorization: a native identity-compatible DataChange InformationReportProven profile must be paired with a separate exact activation/report/RCB/DataSet/dchg/cleanup witness before general Dynamic RCB planning is allowed. Once accepted, the witness proves mechanism capability rather than permanent member scope; static precedence, fresh exact live resolution, fresh verified-free RCB checks, bounded groups, deterministic per-RCB AR_HYB identities and MMS residual fallback remain mandatory, while ProductionEligible stays independent." + "commit": "d108eb5967960e697769b00f2dfe4f21c64688cd", + "sourcePullRequest": 108, + "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary. PR #107, merged on main at c979206988ebcbaf79e62b784895e19547184369, adds P1.7 native per-IED field-capability authorization: a native identity-compatible DataChange InformationReportProven profile must be paired with a separate exact activation/report/RCB/DataSet/dchg/cleanup witness before general Dynamic RCB planning is allowed. Once accepted, the witness proves mechanism capability rather than permanent member scope; static precedence, fresh exact live resolution, fresh verified-free RCB checks, bounded groups, deterministic per-RCB AR_HYB identities and MMS residual fallback remain mandatory, while ProductionEligible stays independent. PR #108, merged on main at d108eb5967960e697769b00f2dfe4f21c64688cd, adds fail-closed G2.3 fresh-association qualification residue recovery: an exact current-run temporary DataSet may be deleted only after fresh readable directory evidence exactly matches the failed ordered member sequence, followed by fresh namespace + direct-directory absence and healthy-association closure; a name match alone never authorizes delete, and ProductionEligible remains independent." } From c81a881d2787c9b5b3d3c0276f0d0816a6c69e09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 12:53:15 +0700 Subject: [PATCH 140/150] test(g2.7): pin G2.3 fresh recovery and PR108 engine --- ...ivePerIedFieldCapabilityRegressionTests.cs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs index 855f286c9..d4f387c35 100644 --- a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs @@ -22,9 +22,11 @@ public void P17_NormalRuntimeRequiresProfilePlusSeparateNativeCleanupWitness() public void P17_ExplicitBootstrapReusesExistingGuardedCommissioningLadder() { var bootstrap = Read("Services/DynamicReportPerIedFieldCapabilityBootstrapService.cs"); + var recovery = Read("Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs"); var persistence = Read("Services/DynamicReportNativeFieldCapabilityPersistenceService.cs"); - Assert.Contains("DynamicReportQualificationCommissioningService", bootstrap, StringComparison.Ordinal); + Assert.Contains("DynamicReportQualificationFreshRecoveryCommissioningService", bootstrap, StringComparison.Ordinal); + Assert.Contains("DynamicReportQualificationCommissioningService", recovery, StringComparison.Ordinal); Assert.Contains("DynamicReportActivationCommissioningServiceV2", bootstrap, StringComparison.Ordinal); Assert.Contains("DynamicReportNativeFieldCapabilityPersistenceService", bootstrap, StringComparison.Ordinal); Assert.Contains("DynamicReportSpontaneousDataChangeCommissioningService", persistence, StringComparison.Ordinal); @@ -33,9 +35,25 @@ public void P17_ExplicitBootstrapReusesExistingGuardedCommissioningLadder() Assert.Contains("MmsDynamicInformationReportKind.DataChange", persistence, StringComparison.Ordinal); Assert.Contains("GeneralInterrogationDisabled = true", persistence, StringComparison.Ordinal); Assert.DoesNotContain("MarkProductionEligible(", bootstrap, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", recovery, StringComparison.Ordinal); Assert.DoesNotContain("MarkProductionEligible(", persistence, StringComparison.Ordinal); } + [Fact] + public void P17_G23FreshRecoveryIsExactBoundedAndOneRetryOnly() + { + var recovery = Read("Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs"); + + Assert.Contains("RequiresFreshAssociation", recovery, StringComparison.Ordinal); + Assert.Contains("RecoverDynamicDataSetQualificationResidueAsync", recovery, StringComparison.Ordinal); + Assert.Contains("IsExactCurrentRunG23TemporaryDataSet", recovery, StringComparison.Ordinal); + Assert.Contains("ARQ<8-hex>", recovery, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exactly one new G2.3 commissioning run", recovery, StringComparison.OrdinalIgnoreCase); + Assert.Contains("repeated physical mutation-instability evidence", recovery, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("while (", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", recovery, StringComparison.Ordinal); + } + [Fact] public void P17_PersistenceBindsActualDchgAndAllCleanupGatesBeforeRuntimeAuthorization() { @@ -68,14 +86,17 @@ public void P17_UiIsExplicitZeroControlAndRequiresReconnectBeforeNormalRuntime() } [Fact] - public void P17_EngineLockPinsMergedNativeFieldCapabilityEngine() + public void P17_EngineLockPinsMergedFreshRecoveryEngine() { var engineLock = Read("engines/ARIEC61850.lock.json"); - Assert.Contains("c979206988ebcbaf79e62b784895e19547184369", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 107", engineLock, StringComparison.Ordinal); + Assert.Contains("d108eb5967960e697769b00f2dfe4f21c64688cd", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 108", engineLock, StringComparison.Ordinal); + Assert.Contains("PR #107", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("native per-IED field-capability authorization", engineLock, StringComparison.OrdinalIgnoreCase); - Assert.Contains("ProductionEligible stays independent", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("fresh-association qualification residue recovery", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name match alone never authorizes delete", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible remains independent", engineLock, StringComparison.OrdinalIgnoreCase); } private static string Read(string relativePath) From ed0c2c193707cbb95ed6c6d2621305342b7abaaf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 13:00:46 +0700 Subject: [PATCH 141/150] build(g2.7): pin hardened G2.3 recovery PR109 --- engines/ARIEC61850.lock.json | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 4fe9b8d0e..8a1ba5274 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,19 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "d108eb5967960e697769b00f2dfe4f21c64688cd", - "sourcePullRequest": 108, - "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary. PR #107, merged on main at c979206988ebcbaf79e62b784895e19547184369, adds P1.7 native per-IED field-capability authorization: a native identity-compatible DataChange InformationReportProven profile must be paired with a separate exact activation/report/RCB/DataSet/dchg/cleanup witness before general Dynamic RCB planning is allowed. Once accepted, the witness proves mechanism capability rather than permanent member scope; static precedence, fresh exact live resolution, fresh verified-free RCB checks, bounded groups, deterministic per-RCB AR_HYB identities and MMS residual fallback remain mandatory, while ProductionEligible stays independent. PR #108, merged on main at d108eb5967960e697769b00f2dfe4f21c64688cd, adds fail-closed G2.3 fresh-association qualification residue recovery: an exact current-run temporary DataSet may be deleted only after fresh readable directory evidence exactly matches the failed ordered member sequence, followed by fresh namespace + direct-directory absence and healthy-association closure; a name match alone never authorizes delete, and ProductionEligible remains independent." + "commit": "127a4ecac0a52b3adc02d5403f207d89838c8010", + "sourcePullRequest": 109, + "lineage": [ + { + "commit": "c979206988ebcbaf79e62b784895e19547184369", + "sourcePullRequest": 107, + "role": "P1.7 native per-IED field-capability runtime" + }, + { + "commit": "d108eb5967960e697769b00f2dfe4f21c64688cd", + "sourcePullRequest": 108, + "role": "G2.3 exact fresh-association residue recovery" + } + ], + "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary. PR #107, merged on main at c979206988ebcbaf79e62b784895e19547184369, adds P1.7 native per-IED field-capability authorization: a native identity-compatible DataChange InformationReportProven profile must be paired with a separate exact activation/report/RCB/DataSet/dchg/cleanup witness before general Dynamic RCB planning is allowed. Once accepted, the witness proves mechanism capability rather than permanent member scope; static precedence, fresh exact live resolution, fresh verified-free RCB checks, bounded groups, deterministic per-RCB AR_HYB identities and MMS residual fallback remain mandatory, while ProductionEligible stays independent. PR #108, merged on main at d108eb5967960e697769b00f2dfe4f21c64688cd, adds fail-closed G2.3 fresh-association qualification residue recovery: an exact current-run temporary DataSet may be deleted only after fresh readable directory evidence exactly matches the failed ordered member sequence, followed by fresh namespace + direct-directory absence and healthy-association closure; a name match alone never authorizes delete, and ProductionEligible remains independent. PR #109, merged on main at 127a4ecac0a52b3adc02d5403f207d89838c8010, hardens G2.3 fresh recovery so cleanup closure requires both successful NamedVariableList namespace absence proof and a completed direct-directory absence proof on a healthy fresh association; discovery or directory exceptions never count as absence evidence." } From 2e0218820997d2b9e5315d9e09ab3ab2969ef860 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 13:01:13 +0700 Subject: [PATCH 142/150] test(g2.7): advance engine pin through hardened recovery PR109 --- .../ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index cb81d24ef..02b2271db 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,8 +12,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("c979206988ebcbaf79e62b784895e19547184369", json.GetProperty("commit").GetString()); - Assert.Equal(107, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("127a4ecac0a52b3adc02d5403f207d89838c8010", json.GetProperty("commit").GetString()); + Assert.Equal(109, json.GetProperty("sourcePullRequest").GetInt32()); var purpose = json.GetProperty("purpose").GetString() ?? string.Empty; // G2.7 may advance the engine pin only while the field-proven G1/G2.3/P0/P1 ancestry @@ -65,6 +65,10 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc Assert.Contains("deterministic AR_HYB_", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #107", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("native per-IED field-capability authorization", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #108", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("fresh-association qualification residue recovery", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #109", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exceptions never count as absence evidence", purpose, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -160,6 +164,8 @@ public void G1_ControlPathDoesNotOwnGuardedDynamicRuntimeOrChangeReconnectPolicy Assert.Contains("PR #104", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #105", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #107", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #108", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #109", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); // G1 control remains independent from the G2.7 report acquisition bridge. From 2440544d1168cdfdf3ff0de9a4ca0eef2378bbe6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:29:28 +0700 Subject: [PATCH 143/150] test(g2.7): retain clean envelope after exact residue recovery --- .../G27RecoveredEnvelopeRegressionTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/ARSAS.Tests/G27RecoveredEnvelopeRegressionTests.cs diff --git a/tests/ARSAS.Tests/G27RecoveredEnvelopeRegressionTests.cs b/tests/ARSAS.Tests/G27RecoveredEnvelopeRegressionTests.cs new file mode 100644 index 000000000..aaaf1a798 --- /dev/null +++ b/tests/ARSAS.Tests/G27RecoveredEnvelopeRegressionTests.cs @@ -0,0 +1,51 @@ +namespace ARSAS.Tests; + +public sealed class G27RecoveredEnvelopeRegressionTests +{ + [Fact] + public void P17_G23Recovery_RetainsLargestPriorCleanEnvelopeBeforeAnyFullRetry() + { + var source = Read("Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs"); + + Assert.Contains("LargestCleanupSafeMultiMemberAttempt", source, StringComparison.Ordinal); + Assert.Contains("MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope", source, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile", source, StringComparison.Ordinal); + Assert.Contains("retained the largest prior cleanup-safe multi-member envelope", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("later failed larger milestone is not generalized", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SaveAsync", source, StringComparison.Ordinal); + Assert.Contains("exactly one new G2.3 commissioning run", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("no earlier cleanup-safe multi-member envelope", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("MarkProductionEligible(", source, StringComparison.Ordinal); + } + + [Fact] + public void P17_G23Recovery_PropagatesExactFieldEvidenceForDiagnosis() + { + var source = Read("Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs"); + + Assert.Contains("failedAttempt=", source, StringComparison.Ordinal); + Assert.Contains("failureStage=", source, StringComparison.Ordinal); + Assert.Contains("associationSurvived=", source, StringComparison.Ordinal); + Assert.Contains("cleanupSucceeded=", source, StringComparison.Ordinal); + Assert.Contains("namespaceAbsenceBefore=", source, StringComparison.Ordinal); + Assert.Contains("directoryAbsenceBefore=", source, StringComparison.Ordinal); + Assert.Contains("namespaceAbsenceAfter=", source, StringComparison.Ordinal); + Assert.Contains("directoryAbsenceAfter=", 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 1124d69ad9c49bef6d5c83e1dcc7a9e313bdbcb0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:30:10 +0700 Subject: [PATCH 144/150] fix(g2.7): retain clean G2.3 envelope after exact recovery --- ...cationFreshRecoveryCommissioningService.cs | 111 ++++++++++++++++-- 1 file changed, 103 insertions(+), 8 deletions(-) diff --git a/Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs b/Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs index 500274e80..975568c10 100644 --- a/Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs +++ b/Services/DynamicReportQualificationFreshRecoveryCommissioningService.cs @@ -9,8 +9,13 @@ namespace ArIED61850Tester.Services; /// The underlying G2.3 coordinator intentionally stops when an attempt loses association /// continuity or cannot prove DeleteNamedVariableList cleanup. That stop remains authoritative. /// This wrapper performs exactly one recovery cycle on a NEW MMS association, using the -/// ARIEC exact-residue recovery primitive, then retries the unchanged G2.3 service once. -/// There is no retry loop and no profile is synthesized from the failed first attempt. +/// ARIEC exact-residue recovery primitive. +/// +/// After exact fresh-association cleanup closure, a prior cleanup-safe, association-surviving +/// multi-member milestone from the same first ladder may be retained as the bounded G2.3 +/// envelope. A later failed larger milestone is never generalized as safe. If there was no +/// earlier cleanup-safe multi-member envelope, the unchanged G2.3 service may be retried once. +/// There is no retry loop and this path never promotes ProductionEligible. /// internal sealed class DynamicReportQualificationFreshRecoveryCommissioningService { @@ -60,8 +65,18 @@ public async Task RunAsync( first.EvidenceLines.Append("G2.3 fresh recovery blocked: temporary DataSet identity failed the ARQ current-run naming contract.").ToArray()); } + var triggerEvidence = first.EvidenceLines + .Append( + $"G2.3 recovery trigger: failedAttempt={failedAttempt.AttemptId}; dataset={failedAttempt.DataSetReference}; " + + $"members={failedAttempt.MemberCount}; failureStage={failedAttempt.FailureStage}; " + + $"associationSurvived={failedAttempt.AssociationSurvived}; cleanupSucceeded={failedAttempt.CleanupSucceeded}; " + + $"dynamicMutationAttempted={failedAttempt.DynamicMutationAttempted}.") + .ToArray(); + progress?.Report( - $"G2.7 P1.7 G2.3 recovery: first bounded attempt requires a fresh association. Closing the failed transaction and inspecting exact temporary DataSet {failedAttempt.DataSetReference} before any retry…"); + $"G2.7 P1.7 G2.3 recovery: failedAttempt={failedAttempt.AttemptId}; members={failedAttempt.MemberCount}; " + + $"failureStage={failedAttempt.FailureStage}; associationSurvived={failedAttempt.AssociationSurvived}; " + + $"cleanupSucceeded={failedAttempt.CleanupSucceeded}. Opening a fresh association to inspect exact temporary DataSet {failedAttempt.DataSetReference}…"); ArMms.MmsDynamicDataSetQualificationRecoveryResult recovery; await using (var fresh = new ArMms.MmsClientSession()) @@ -80,7 +95,7 @@ await fresh.ConnectAsync( } catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException or ArgumentException) { - var evidence = first.EvidenceLines + var evidence = triggerEvidence .Append($"G2.3 fresh recovery exception: {ex.GetType().Name}: {ex.Message}") .ToArray(); return Copy( @@ -91,9 +106,14 @@ await fresh.ConnectAsync( } } - var combined = first.EvidenceLines + var combined = triggerEvidence .Concat(recovery.EvidenceLines.Select(line => "G2.3 recovery: " + line)) - .Append($"G2.3 recovery result: success={recovery.IsSuccess}; deleteAttempted={recovery.DeleteAttempted}; exactMembers={recovery.ExactMembersVerifiedBeforeDelete}; associationHealthy={recovery.AssociationHealthy}; summary={recovery.Summary}") + .Append( + $"G2.3 recovery result: success={recovery.IsSuccess}; deleteAttempted={recovery.DeleteAttempted}; " + + $"exactMembers={recovery.ExactMembersVerifiedBeforeDelete}; associationHealthy={recovery.AssociationHealthy}; " + + $"namespaceAbsenceBefore={recovery.NamespaceAbsenceProvenBefore}; directoryAbsenceBefore={recovery.DirectoryAbsenceProvenBefore}; " + + $"namespaceAbsenceAfter={recovery.NamespaceAbsenceProvenAfter}; directoryAbsenceAfter={recovery.DirectoryAbsenceProvenAfter}; " + + $"summary={recovery.Summary}") .ToArray(); if (!recovery.IsSuccess) @@ -105,14 +125,77 @@ await fresh.ConnectAsync( combined); } + var retainedAttempt = LargestCleanupSafeMultiMemberAttempt(first.Coordinator); + if (retainedAttempt is not null && first.Identity is not null) + { + try + { + var acceptedEnvelope = ArMms.MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope( + first.Coordinator.Assessment, + retainedAttempt.AttemptId); + var fieldEvidenceId = + $"arsas-g2.3-recovered-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}"; + var profile = ArMms.MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile( + first.Identity, + acceptedEnvelope, + first.Coordinator.Assessment, + capacityEvidence: null, + sourceEvidenceId: fieldEvidenceId, + nowUtc: DateTimeOffset.UtcNow); + + await _profileStore.SaveAsync(profile, cancellationToken).ConfigureAwait(false); + var profilePath = _profileStore.GetProfilePath(first.Identity); + var evidence = combined + .Append( + $"G2.3 recovered-envelope acceptance: sourceAttempt={retainedAttempt.AttemptId}; " + + $"members={retainedAttempt.MemberCount}; requestBytes={retainedAttempt.DefineRequestByteCount}; " + + $"profileState={profile.State}; path={profilePath}; laterFailedAttempt={failedAttempt.AttemptId}; " + + "later failed larger milestone is not generalized.") + .Append( + "G2.3 recovered-envelope safety: exact failed residue closure was proven first; EnvelopeQualified is NOT RcbActivationProven, NOT InformationReportProven, and NOT ProductionEligible.") + .ToArray(); + + progress?.Report( + $"G2.7 P1.7 G2.3 recovery PASS: exact residue closure proven. Retained the largest prior cleanup-safe multi-member envelope " + + $"({retainedAttempt.MemberCount} member(s), attempt={retainedAttempt.AttemptId}). The later failed larger milestone is not generalized. Continuing to G2.4…"); + + return new DynamicReportQualificationCommissioningResult + { + IsSuccess = true, + IsBlocked = false, + Summary = + $"G2.3 fresh-association recovery PASS and retained the largest prior cleanup-safe multi-member envelope: " + + $"{retainedAttempt.MemberCount} member(s) from {retainedAttempt.AttemptId}. The later failed larger milestone is not generalized; " + + "G2.4/G2.5 physical report proof is still required before normal-runtime Dynamic RCB authorization.", + Identity = first.Identity, + Candidates = first.Candidates, + Coordinator = first.Coordinator, + SavedProfile = profile, + ProfilePath = profilePath, + EvidenceLines = evidence + }; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + var evidence = combined + .Append($"G2.3 recovered-envelope persistence failed closed: {ex.GetType().Name}: {ex.Message}") + .ToArray(); + return Copy( + first, + false, + "G2.3 exact residue recovery passed, but the prior clean envelope could not be safely persisted. No profile advancement was allowed. " + ex.Message, + evidence); + } + } + progress?.Report( - "G2.7 P1.7 G2.3 recovery PASS: exact temporary qualification residue is closed on a fresh association. Retrying the unchanged bounded G2.3 ladder exactly once on another fresh association…"); + "G2.7 P1.7 G2.3 recovery PASS, but no earlier cleanup-safe multi-member envelope exists. Retrying the unchanged bounded G2.3 ladder exactly once on another fresh association…"); var retry = await new DynamicReportQualificationCommissioningService(_profileStore) .RunAsync(device, fullModelSignals, cancellationToken) .ConfigureAwait(false); var retryEvidence = combined - .Append("G2.3 one-retry boundary: recovery passed; exactly one new G2.3 commissioning run was started.") + .Append("G2.3 one-retry boundary: recovery passed; no earlier cleanup-safe multi-member envelope existed; exactly one new G2.3 commissioning run was started.") .Concat(retry.EvidenceLines.Select(line => "G2.3 retry: " + line)) .ToArray(); @@ -135,6 +218,18 @@ await fresh.ConnectAsync( retryEvidence); } + internal static ArMms.MmsDynamicDataSetQualificationAttemptEvidence? LargestCleanupSafeMultiMemberAttempt( + ArMms.MmsDynamicDataSetQualificationCoordinatorResult coordinator) + { + ArgumentNullException.ThrowIfNull(coordinator); + return coordinator.Attempts + .Where(attempt => attempt.IsQualificationSuccess && attempt.MemberCount > 1) + .OrderByDescending(attempt => attempt.MemberCount) + .ThenByDescending(attempt => attempt.DefineRequestByteCount) + .ThenBy(attempt => attempt.AttemptId, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + } + internal static bool IsExactCurrentRunG23TemporaryDataSet(string? reference) { var text = (reference ?? string.Empty).Trim().Replace('$', '.'); From aa5e2da4669b6ac5005b08d28ddcc3fd0e3228b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:32:33 +0700 Subject: [PATCH 145/150] chore(g2.7): pin envelope-bounded runtime engine PR110 --- engines/ARIEC61850.lock.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 8a1ba5274..f363b6b03 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "127a4ecac0a52b3adc02d5403f207d89838c8010", - "sourcePullRequest": 109, + "commit": "9b60458ed910a410b843185384f0e04d3ca78ce0", + "sourcePullRequest": 110, "lineage": [ { "commit": "c979206988ebcbaf79e62b784895e19547184369", @@ -14,7 +14,12 @@ "commit": "d108eb5967960e697769b00f2dfe4f21c64688cd", "sourcePullRequest": 108, "role": "G2.3 exact fresh-association residue recovery" + }, + { + "commit": "127a4ecac0a52b3adc02d5403f207d89838c8010", + "sourcePullRequest": 109, + "role": "G2.3 hardened namespace plus direct-directory absence proof" } ], - "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary. PR #107, merged on main at c979206988ebcbaf79e62b784895e19547184369, adds P1.7 native per-IED field-capability authorization: a native identity-compatible DataChange InformationReportProven profile must be paired with a separate exact activation/report/RCB/DataSet/dchg/cleanup witness before general Dynamic RCB planning is allowed. Once accepted, the witness proves mechanism capability rather than permanent member scope; static precedence, fresh exact live resolution, fresh verified-free RCB checks, bounded groups, deterministic per-RCB AR_HYB identities and MMS residual fallback remain mandatory, while ProductionEligible stays independent. PR #108, merged on main at d108eb5967960e697769b00f2dfe4f21c64688cd, adds fail-closed G2.3 fresh-association qualification residue recovery: an exact current-run temporary DataSet may be deleted only after fresh readable directory evidence exactly matches the failed ordered member sequence, followed by fresh namespace + direct-directory absence and healthy-association closure; a name match alone never authorizes delete, and ProductionEligible remains independent. PR #109, merged on main at 127a4ecac0a52b3adc02d5403f207d89838c8010, hardens G2.3 fresh recovery so cleanup closure requires both successful NamedVariableList namespace absence proof and a completed direct-directory absence proof on a healthy fresh association; discovery or directory exceptions never count as absence evidence." + "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. PR #101, merged on main at e7cf12ea3c9b8e62f82d42dcf73d43b28a709378, adds the fail-closed P1.5 legacy compatibility adapter for exact full-sequence matches and retains the original persisted profile as certification evidence. PR #102, merged on main at 0965f67fe912355b3b29fc8123872a68d4064b04, closes P1.5b for the real broader legacy chain: the persisted GI-classified activation/report sequence and qualification envelope remain unchanged, while a separately proven NO-GI dchg sequence may authorize its exact ordered subset on the same exact current identity and RCB without profile save/mutation and never authorizes ProductionEligible. PR #104, merged on main at 0336d63366f9c367d74fc3fddb9bba5e47fbaf00, restores the original Smart Auto contract for the reviewed field identity: the Q0/A3 NO-GI dchg sequence is capability evidence rather than permanent member scope; static coverage keeps precedence, then all still-uncovered exact-resolved selected signals may be partitioned into bounded dynamic DataSets on freshly exact-verified free RCBs, with MMS polling only for genuine residuals. PR #105, merged on main at 4d7a896c606194c5533322bf975a2c9c57da7c64, makes multi-RCB execution revalidation safe by deriving a deterministic AR_HYB_ temporary DataSet identity from each exact RCB reference, so full planning and isolated pre-write revalidation keep the same DataSet reference without cross-RCB collision. P1.6 retains identity/profile binding, fresh live capability and RCB availability checks, configured limits, cleanup/reconnect/circuit-breaker enforcement in the caller, no profile save/mutation, does not call MarkProductionEligible, and keeps ProductionEligible as a separate certification boundary. PR #107, merged on main at c979206988ebcbaf79e62b784895e19547184369, adds P1.7 native per-IED field-capability authorization: a native identity-compatible DataChange InformationReportProven profile must be paired with a separate exact activation/report/RCB/DataSet/dchg/cleanup witness before general Dynamic RCB planning is allowed. Once accepted, the witness proves mechanism capability rather than permanent member scope; static precedence, fresh exact live resolution, fresh verified-free RCB checks, bounded groups, deterministic per-RCB AR_HYB identities and MMS residual fallback remain mandatory, while ProductionEligible stays independent. PR #108, merged on main at d108eb5967960e697769b00f2dfe4f21c64688cd, adds fail-closed G2.3 fresh-association qualification residue recovery: an exact current-run temporary DataSet may be deleted only after fresh readable directory evidence exactly matches the failed ordered member sequence, followed by fresh namespace + direct-directory absence and healthy-association closure; a name match alone never authorizes delete, and ProductionEligible remains independent. PR #109, merged on main at 127a4ecac0a52b3adc02d5403f207d89838c8010, hardens G2.3 fresh recovery so cleanup closure requires both successful NamedVariableList namespace absence proof and a completed direct-directory absence proof on a healthy fresh association; discovery or directory exceptions never count as absence evidence. PR #110, merged on main at 9b60458ed910a410b843185384f0e04d3ca78ce0, adds the P1.7 envelope-bounded native runtime wrapper: the physical dchg witness still proves general member capability, but each runtime Dynamic DataSet is capped to the G2.3 ProvenSafeMemberCount so a relay that destabilizes at a larger NamedVariableList size is never asked to exceed its physically proven envelope. Multi-group coverage, deterministic AR_HYB identities, fresh RCB checks, residual polling fallback, and ProductionEligible separation remain intact." } From 99ddd432613b4e264043e5b0689c04a89d7b9236 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:33:16 +0700 Subject: [PATCH 146/150] feat(g2.7): maximize bounded native dynamic coverage --- ...50Client.HybridReporting.GuardedRuntime.cs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs index be7fc577b..b2869edb2 100644 --- a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -7,6 +7,8 @@ namespace ArIED61850Tester.Services; public sealed partial class NativeIec61850Client { + private const int NativeFieldCapabilityAbsoluteDynamicPlanLimit = 64; + private readonly Dictionary _guardedRuntimeContexts = new(StringComparer.OrdinalIgnoreCase); @@ -165,14 +167,15 @@ private ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabilityPlanW guardedContext.CurrentIdentity.StableIdentityKey, out var nativeEvidence)) { - return ArMms.MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner.Build( + var nativeOptions = BuildNativeFieldCapabilityOptions(options, availability); + return ArMms.MmsGuardedDynamicReportNativeFieldCapabilityEnvelopeBoundRuntimePlanner.Build( catalog, requestedSignals, inventory, availability, liveDirectory, negotiatedCapabilities, - options, + nativeOptions, guardedContext, nativeEvidence); } @@ -221,6 +224,38 @@ private ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabilityPlanW guardedContext); } + private static ArMms.MmsHybridReportAcquisitionOptions BuildNativeFieldCapabilityOptions( + ArMms.MmsHybridReportAcquisitionOptions source, + ArMms.MmsRcbAvailabilityResult availability) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(availability); + + // The generic planner default of eight Dynamic RCB plans is intentionally conservative, + // but after a complete P1.7 per-IED physical capability witness it can strand otherwise + // eligible exact-resolved signals on polling solely because of that generic budget. + // Expand only the native P1.7 plan budget, bounded by this association's freshly checked + // RCB inventory and an application hard ceiling. The ARIEC planner still admits only + // exact verified-free slots, and PR #110 separately caps each DataSet to ProvenSafeMemberCount. + var associationBoundPlanLimit = Math.Min( + NativeFieldCapabilityAbsoluteDynamicPlanLimit, + Math.Max(source.MaxDynamicReportPlans, availability.ReportControls.Count)); + + return new ArMms.MmsHybridReportAcquisitionOptions + { + MaxStaticReportPlans = source.MaxStaticReportPlans, + MaxDynamicReportPlans = associationBoundPlanLimit, + MaxDynamicMembersPerReport = source.MaxDynamicMembersPerReport, + RequireExactAvailabilityEvidence = source.RequireExactAvailabilityEvidence, + AllowCallerOwnedReports = source.AllowCallerOwnedReports, + AllowStaticBrcb = source.AllowStaticBrcb, + AllowStaticUrcb = source.AllowStaticUrcb, + AllowDynamicBrcb = source.AllowDynamicBrcb, + AllowDynamicUrcb = source.AllowDynamicUrcb, + AllowPollingFallback = source.AllowPollingFallback + }; + } + private bool TryGetGuardedRuntimeContext( string planId, out ArMms.MmsDynamicReportGuardedRuntimePlanningContext context) From 49e727c44580e89bfd557d2ab1fe54d65536fd41 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:33:31 +0700 Subject: [PATCH 147/150] test(g2.7): require envelope-bound full native coverage budget --- .../G27NativeCoverageBudgetRegressionTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/ARSAS.Tests/G27NativeCoverageBudgetRegressionTests.cs diff --git a/tests/ARSAS.Tests/G27NativeCoverageBudgetRegressionTests.cs b/tests/ARSAS.Tests/G27NativeCoverageBudgetRegressionTests.cs new file mode 100644 index 000000000..1b22b1e01 --- /dev/null +++ b/tests/ARSAS.Tests/G27NativeCoverageBudgetRegressionTests.cs @@ -0,0 +1,48 @@ +namespace ARSAS.Tests; + +public sealed class G27NativeCoverageBudgetRegressionTests +{ + [Fact] + public void P17_NativeRuntime_UsesEnvelopeBoundEnginePlannerAndAssociationBoundPlanBudget() + { + var source = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + + Assert.Contains("NativeFieldCapabilityAbsoluteDynamicPlanLimit = 64", source, StringComparison.Ordinal); + Assert.Contains("BuildNativeFieldCapabilityOptions", source, StringComparison.Ordinal); + Assert.Contains("availability.ReportControls.Count", source, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityEnvelopeBoundRuntimePlanner.Build", source, StringComparison.Ordinal); + Assert.Contains("ProvenSafeMemberCount", source, StringComparison.Ordinal); + Assert.Contains("exact verified-free slots", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("generic budget", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void P17_EngineLock_PinsPr110AndRetainsRecoveryLineage() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("9b60458ed910a410b843185384f0e04d3ca78ce0", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 110", engineLock, StringComparison.Ordinal); + Assert.Contains("PR #108", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #109", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #110", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("envelope-bounded native runtime", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProvenSafeMemberCount", engineLock, 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 32fe7f6b4060a6b8872088f73f4206c3de5b4251 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:35:36 +0700 Subject: [PATCH 148/150] test(g2.7): advance control lineage through envelope-bound PR110 --- tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index 02b2271db..80efa606c 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -12,8 +12,8 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc var json = doc.RootElement; Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString()); Assert.Equal("main", json.GetProperty("ref").GetString()); - Assert.Equal("127a4ecac0a52b3adc02d5403f207d89838c8010", json.GetProperty("commit").GetString()); - Assert.Equal(109, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("9b60458ed910a410b843185384f0e04d3ca78ce0", json.GetProperty("commit").GetString()); + Assert.Equal(110, json.GetProperty("sourcePullRequest").GetInt32()); var purpose = json.GetProperty("purpose").GetString() ?? string.Empty; // G2.7 may advance the engine pin only while the field-proven G1/G2.3/P0/P1 ancestry @@ -69,6 +69,9 @@ public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAnc Assert.Contains("fresh-association qualification residue recovery", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #109", purpose, StringComparison.OrdinalIgnoreCase); Assert.Contains("exceptions never count as absence evidence", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #110", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("envelope-bounded native runtime", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProvenSafeMemberCount", purpose, StringComparison.Ordinal); } [Fact] @@ -166,6 +169,7 @@ public void G1_ControlPathDoesNotOwnGuardedDynamicRuntimeOrChangeReconnectPolicy Assert.Contains("PR #107", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #108", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #109", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #110", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); // G1 control remains independent from the G2.7 report acquisition bridge. From 0047a22a4b0d35d6c58b70e5d774cc082e56ecf9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:40:07 +0700 Subject: [PATCH 149/150] test(g2.7): expect envelope-bound native runtime planner --- .../G27NativePerIedFieldCapabilityRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs index d4f387c35..6803fd5eb 100644 --- a/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/G27NativePerIedFieldCapabilityRegressionTests.cs @@ -10,7 +10,7 @@ public void P17_NormalRuntimeRequiresProfilePlusSeparateNativeCleanupWitness() Assert.Contains("DynamicReportNativeFieldCapabilityWitnessStore", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); - Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityEnvelopeBoundRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("DataChange profile is present but general Dynamic RCB runtime remains withheld", guarded, StringComparison.OrdinalIgnoreCase); Assert.Contains("ModelFingerprint", store, StringComparison.Ordinal); Assert.Contains("ProfileRevision", store, StringComparison.Ordinal); From af75fcef4b8f78ad4334d81abd921e7564d596ad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 3 Sep 2026 14:40:28 +0700 Subject: [PATCH 150/150] test(g2.7): expect envelope-bound native revalidation planner --- tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs index deed0a2d9..51d228bfa 100644 --- a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -26,7 +26,7 @@ public void InitialPlanningAndExecutionRevalidation_UseLegacyP16OrNativeP17Field Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); - Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityStableRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityEnvelopeBoundRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsGuardedDynamicReportNativeFieldCapabilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.DoesNotContain("MmsGuardedDynamicReportLegacySubsetCompatibilityPolicy.TryValidate", guarded, StringComparison.Ordinal); Assert.Contains("capability, not member scope", guarded, StringComparison.OrdinalIgnoreCase); @@ -99,6 +99,8 @@ public void EngineLock_PinsMergedP17WhileRetainingP16StableGeneralDynamicLineage Assert.Contains("4d7a896c606194c5533322bf975a2c9c57da7c64", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("deterministic AR_HYB_", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("PR #107", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #110", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProvenSafeMemberCount", engineLock, StringComparison.Ordinal); Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); Assert.Contains("ProductionEligible stays independent", engineLock, StringComparison.OrdinalIgnoreCase); } @@ -130,4 +132,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} \ No newline at end of file +}