From cce97f4fbc24eb89265f407dcfadedf82d3409f3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 09:08:21 +0700 Subject: [PATCH 01/12] G2.5-A: add spontaneous dchg commissioning proof --- ...ontaneousDataChangeCommissioningService.cs | 758 ++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 Services/DynamicReportSpontaneousDataChangeCommissioningService.cs diff --git a/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs b/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs new file mode 100644 index 00000000..7f855f43 --- /dev/null +++ b/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs @@ -0,0 +1,758 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportSpontaneousDataChangeValidation +{ + public bool IsSuccess { get; init; } + public string Reason { get; init; } = string.Empty; + public IReadOnlyList IncludedIndexes { get; init; } = Array.Empty(); + public IReadOnlyList IncludedMemberReferences { get; init; } = Array.Empty(); + public IReadOnlyList Reasons { get; init; } = Array.Empty(); +} + +internal sealed class DynamicReportSpontaneousDataChangeCommissioningResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public bool ActivationProven { get; init; } + public bool SpontaneousDataChangeProven { get; init; } + public bool MonitorCleanupSucceeded { get; init; } + public bool ProofFieldRestoreSucceeded { get; init; } + public bool FreshCleanupClosureSucceeded { get; init; } + public bool AssociationHealthyAfterReport { get; init; } + public string Summary { get; init; } = string.Empty; + public ArMms.MmsDynamicReportIedIdentity? Identity { get; init; } + public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; } + public string RcbReference { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public string ReportId { get; init; } = string.Empty; + public IReadOnlyList MemberReferences { get; init; } = Array.Empty(); + public IReadOnlyList IncludedIndexes { get; init; } = Array.Empty(); + public IReadOnlyList IncludedMemberReferences { get; init; } = Array.Empty(); + public IReadOnlyList Reasons { get; init; } = Array.Empty(); + public string ProfilePath { get; init; } = string.Empty; + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// G2.5-A explicit commissioning gate for one spontaneous dchg InformationReport. +/// +/// This gate consumes only an identity-compatible InformationReportProven profile, +/// reuses the exact G2.4-proven URCB and exact eight-member set, enables dchg ONLY, +/// never requests GI, and waits for a real spontaneous process/status change. It does +/// not change the persisted profile or production monitoring policy. PASS also requires +/// monitor cleanup, exact proof-field restore, and a second fresh-association read-only +/// cleanup closure. +/// +internal sealed class DynamicReportSpontaneousDataChangeCommissioningService +{ + private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10); + internal static readonly TimeSpan SpontaneousProofWindow = TimeSpan.FromSeconds(60); + internal const string TemporaryTriggerOptions = "dchg"; + internal const string TemporaryOptionalFields = "reason-for-inclusion data-set-name"; + internal const string ExpectedCanonicalTriggerRaw = "0240"; + internal const string ExpectedCanonicalOptionalFieldsRaw = "061800"; + + private readonly DynamicReportQualificationProfileStore _profileStore; + + public DynamicReportSpontaneousDataChangeCommissioningService( + 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 evidence = new List(); + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("G2.5-A identity preflight failed: " + ex.Message, evidence); + } + + evidence.Add($"G2.5-A identity stableKey={identity.StableIdentityKey}; fingerprint={identity.ModelFingerprint}; profileRevision={TextOrDash(identity.ProfileRevision)}"); + + var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.5-A persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; reason={loaded.Reason}"); + if (!loaded.IsValid || loaded.Profile is null) + return Blocked("G2.5-A requires the identity-compatible InformationReportProven profile from merged G2.4.", evidence, identity, loaded.FilePath); + + var profile = loaded.Profile; + if (profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven || + profile.AcceptedEnvelope is null || + profile.RcbActivationProof?.IsSuccess != true || + profile.InformationReportProof?.IsSuccess != true) + { + return Blocked( + $"G2.5-A requires a complete InformationReportProven profile; current state is {profile.State}.", + evidence, + identity, + loaded.FilePath, + profile); + } + + var rcbReference = profile.RcbActivationProof.RcbReference; + var qualifiedReferences = profile.RcbActivationProof.MemberReferences.ToArray(); + if (string.IsNullOrWhiteSpace(rcbReference) || + qualifiedReferences.Length == 0 || + qualifiedReferences.Length > DynamicReportActivationCommissioningService.MaximumG24Members) + { + return Blocked("G2.5-A profile does not retain a usable exact one-URCB/eight-member G2.4 target.", evidence, identity, loaded.FilePath, profile); + } + + evidence.Add($"G2.5-A profile gate: state={profile.State}; rcb={rcbReference}; members={qualifiedReferences.Length}; temporaryTrgOps={TemporaryTriggerOptions}; expectedTrgOpsRaw={ExpectedCanonicalTriggerRaw}; temporaryOptFlds={TemporaryOptionalFields}; expectedOptFldsRaw={ExpectedCanonicalOptionalFieldsRaw}"); + evidence.Add("G2.5-A exact members: " + string.Join(" | ", qualifiedReferences)); + evidence.Add("G2.5-A trigger contract: dchg ONLY. GI=false, integrity=false, qchg=false, dupd=false. No GI request is sent at monitor start or receive time."); + evidence.Add("G2.5-A profile contract: the persisted InformationReportProven profile is READ ONLY and will not be saved, downgraded, or advanced by this gate."); + + var auxiliary = new ArMms.MmsClientSession(); + ArMms.MmsDynamicRcbCommissioningFieldLease? fieldLease = null; + ArMms.MmsPersistentReportMonitorSession? monitorSession = null; + ArMms.MmsReportSubscriptionPlan? plan = null; + ArMms.MmsReportControlCandidate? selectedRcb = null; + var activationProven = false; + var spontaneousProven = false; + var associationHealthyAfterReport = false; + var monitorCleanup = false; + var fieldRestore = false; + var includedIndexes = Array.Empty(); + var includedMembers = Array.Empty(); + var includedReasons = Array.Empty(); + var reportId = string.Empty; + var activationAttempted = false; + + try + { + progress?.Report("G2.5-A: opening isolated MMS association and revalidating the exact G2.4-proven target…"); + await auxiliary.ConnectAsync(device.IpAddress, device.Port, AuxiliaryAssociationTimeout, cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.5-A auxiliary association ready: state={auxiliary.State}; localTcpAddress={TextOrDash(auxiliary.LocalTcpAddress)}; handshake={TextOrDash(auxiliary.LastHandshakeMessage)}"); + + var discovery = await auxiliary.DiscoverAsync( + probeReportAttributes: true, + maxReportAttributeProbes: 64, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add("G2.5-A discovery: " + discovery.Summary); + + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + discovery.IedDirectory, + qualifiedReferences, + out var exactPoints, + out var exactReason)) + { + evidence.Add("G2.5-A exact member revalidation failed: " + exactReason); + return Failed("The exact G2.4-proven member set no longer maps to the live model. No RCB mutation was attempted.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); + } + + foreach (var point in exactPoints) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await auxiliary.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.5-A direct-read {point.MmsReference}: success={read.IsSuccess}; result={read.Message}"); + if (!read.IsSuccess || !auxiliary.IsMmsInitiated) + return Failed("An exact G2.4-proven member failed fresh direct MMS validation. No RCB mutation was attempted.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); + } + + selectedRcb = discovery.ReportInventory.ReportControls.FirstOrDefault(candidate => SameReference(candidate.Reference, rcbReference)); + if (selectedRcb is null || selectedRcb.Buffered) + { + evidence.Add("G2.5-A exact URCB lookup failed or resolved to a buffered RCB."); + return Failed("The exact G2.4-proven URCB is not available in fresh discovery. No mutation was attempted.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); + } + + var oneRcb = new ArMms.MmsReportInventory(); + oneRcb.ReportControls.Add(selectedRcb); + var preLeaseAvailability = await auxiliary.CheckReportControlAvailabilityAsync( + oneRcb, + discovery.IedDirectory, + new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, + cancellationToken).ConfigureAwait(false); + var preLeaseSnapshot = preLeaseAvailability.ReportControls.SingleOrDefault(); + evidence.Add("G2.5-A pre-lease availability: " + preLeaseAvailability.Summary); + if (preLeaseSnapshot is not null) + evidence.Add($"G2.5-A pre-lease URCB: availability={preLeaseSnapshot.Availability}; probe={preLeaseSnapshot.DataSetProbeState}; DatSet={TextOrDash(preLeaseSnapshot.DataSetReference)}; RptEna={TextOrDash(preLeaseSnapshot.EnabledState)}; Resv={TextOrDash(preLeaseSnapshot.ReservationState)}; Owner={TextOrDash(preLeaseSnapshot.Owner)}; RptID={TextOrDash(preLeaseSnapshot.ReportId)}; TrgOps={TextOrDash(preLeaseSnapshot.TriggerOptions)}; OptFlds={TextOrDash(preLeaseSnapshot.OptionalFields)}"); + + if (preLeaseSnapshot is null || + !DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(preLeaseSnapshot, out var preLeaseReason)) + { + evidence.Add("G2.5-A pre-lease URCB rejected: " + (preLeaseSnapshot is null ? "snapshot missing" : preLeaseReason)); + return Failed("The exact G2.4-proven URCB is not freshly proven free. No proof-field mutation was attempted.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); + } + + ApplyFreshSnapshot(selectedRcb, preLeaseSnapshot); + + var fieldPrepare = await auxiliary.PrepareDynamicRcbCommissioningFieldsAsync( + selectedRcb, + TemporaryTriggerOptions, + TemporaryOptionalFields, + cancellationToken).ConfigureAwait(false); + AppendWriteSteps(evidence, "G2.5-A proof-field prepare", fieldPrepare.WriteSteps); + foreach (var line in fieldPrepare.Evidence) + evidence.Add("G2.5-A proof-field prepare: " + line); + evidence.Add($"G2.5-A proof-field prepare result: success={fieldPrepare.IsSuccess}; rollback={fieldPrepare.CleanupSucceeded}; result={fieldPrepare.Message}"); + + if (!fieldPrepare.IsSuccess || fieldPrepare.Lease is null) + { + return new DynamicReportSpontaneousDataChangeCommissioningResult + { + IsSuccess = false, + Summary = fieldPrepare.CleanupSucceeded + ? "G2.5-A dchg-only proof-field preparation failed, but exact rollback passed. The InformationReportProven profile is unchanged." + : "G2.5-A proof-field preparation failed and rollback was not fully proven. Inspect the URCB from a fresh association before retry.", + Identity = identity, + InputProfile = profile, + RcbReference = rcbReference, + MemberReferences = qualifiedReferences, + ProofFieldRestoreSucceeded = fieldPrepare.CleanupSucceeded, + ProfilePath = loaded.FilePath, + EvidenceLines = evidence + }; + } + + fieldLease = fieldPrepare.Lease; + evidence.Add($"G2.5-A proof-field lease ACTIVE: originalTrgOps={fieldLease.OriginalTriggerOptionsText}; originalOptFlds={fieldLease.OriginalOptionalFieldsText}; temporaryTrgOps=dchg-only/{ExpectedCanonicalTriggerRaw}; temporaryOptFlds=reason+dataset/{ExpectedCanonicalOptionalFieldsRaw}; GI=false"); + + var dataSetName = "AR_G25A_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); + plan = ArMms.MmsReportSubscriptionPlanner.BuildDynamicPlan( + discovery.ReportInventory, + discovery.IedDirectory, + exactPoints.Select(point => point.UserReference), + preferredLogicalDevice: selectedRcb.Domain, + preferredRcbReference: selectedRcb.Reference, + dataSetName: dataSetName, + strictRcb: true, + allowUrCbFallback: true, + allowPollingFallback: false); + + if (!DynamicReportActivationCommissioningService.ValidatePlanAgainstEnvelope(plan, selectedRcb.Reference, qualifiedReferences, out var planReason)) + { + evidence.Add("G2.5-A plan rejected: " + planReason); + return Failed("The G2.5-A strict plan did not preserve the exact G2.4-proven one-URCB/member identity.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences, plan.DataSetReference); + } + evidence.Add($"G2.5-A plan: rcb={plan.ReportControl!.Reference}; dataset={plan.DataSetReference}; members={plan.DynamicPoints.Count}; mode={plan.Mode}; GI=false"); + + var finalAvailability = await auxiliary.CheckReportControlAvailabilityAsync( + oneRcb, + discovery.IedDirectory, + DynamicReportActivationCommissioningServiceV2.BuildPostLeaseAvailabilityOptions(selectedRcb.Reference), + cancellationToken).ConfigureAwait(false); + var postLeaseSnapshot = finalAvailability.ReportControls.SingleOrDefault(); + evidence.Add($"G2.5-A post-lease ownership: availability={postLeaseSnapshot?.Availability}; Resv={TextOrDash(postLeaseSnapshot?.ReservationState)}; Owner={TextOrDash(postLeaseSnapshot?.Owner)}; localTcpAddress={TextOrDash(auxiliary.LocalTcpAddress)}; TrgOps={TextOrDash(postLeaseSnapshot?.TriggerOptions)}; OptFlds={TextOrDash(postLeaseSnapshot?.OptionalFields)}"); + if (!IsPostLeaseUrcbSafeForDchg(postLeaseSnapshot, auxiliary.LocalTcpAddress, out var postLeaseReason)) + { + evidence.Add("G2.5-A post-lease URCB rejected: " + postLeaseReason); + return Failed("The exact URCB did not retain strict dchg-only caller-owned state after the proof-field lease.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences, plan.DataSetReference); + } + + ApplyFreshSnapshot(plan.ReportControl!, postLeaseSnapshot!); + EnsureAttribute(plan.ReportControl!, "TrgOps"); + EnsureAttribute(plan.ReportControl!, "OptFlds"); + plan.ReportControl!.TriggerOptions = TemporaryTriggerOptions; + plan.ReportControl.OptionalFields = TemporaryOptionalFields; + selectedRcb.TriggerOptions = TemporaryTriggerOptions; + selectedRcb.OptionalFields = TemporaryOptionalFields; + reportId = postLeaseSnapshot!.ReportId; + + activationAttempted = true; + var attempt = await auxiliary.StartPersistentReportMonitorWithAttemptEvidenceAsync( + plan, + triggerGeneralInterrogation: false, + deleteDynamicDataSetOnStop: true, + directory: discovery.IedDirectory, + cancellationToken: cancellationToken).ConfigureAwait(false); + AppendWriteSteps(evidence, "G2.5-A activation", attempt.StartResult.WriteSteps); + foreach (var warning in attempt.StartResult.Warnings) + evidence.Add("G2.5-A activation warning: " + warning); + + if (!attempt.IsSuccess || attempt.StartResult.Session is null) + { + AppendWriteSteps(evidence, "G2.5-A failed-start cleanup", attempt.CleanupSteps); + foreach (var warning in attempt.CleanupWarnings) + evidence.Add("G2.5-A failed-start cleanup warning: " + warning); + monitorCleanup = attempt.CleanupSucceeded; + evidence.Add($"G2.5-A activation failed: cleanup={attempt.CleanupSucceeded}; reason={attempt.FailureReason}; result={attempt.StartResult.Message}"); + return Failed("G2.5-A could not arm the dchg-only monitor. Existing failed-start cleanup evidence is retained; the profile is unchanged.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences, plan.DataSetReference, monitorCleanup); + } + + monitorSession = attempt.StartResult.Session; + var readback = await auxiliary.GetDataSetDirectoryAsync(plan.DataSetReference, discovery.IedDirectory, cancellationToken).ConfigureAwait(false); + var exactReadback = readback.IsSuccess && ExactSequenceEquals(qualifiedReferences, readback.Members.Select(member => member.MmsReference)); + evidence.Add($"G2.5-A DataSet readback: success={readback.IsSuccess}; exact={exactReadback}; members={readback.Members.Count}; result={readback.Message}"); + evidence.Add("G2.5-A DataSet readback members: " + string.Join(" | ", 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 && auxiliary.IsMmsInitiated; + evidence.Add($"G2.5-A activation proof: success={activationProven}; datasetReadback={exactReadback}; binding={bindingAccepted}; RptEna={rptEnaAccepted}; associationHealthy={auxiliary.IsMmsInitiated}; GIrequested=false"); + + if (activationProven) + { + progress?.Report($"G2.5-A ARMED — NO GI. Within {SpontaneousProofWindow.TotalSeconds:0}s, cause ONE normal physical/status change affecting one of the 8 proven points. Do not edit any RCB/DataSet manually."); + evidence.Add($"G2.5-A ARMED: monitor is enabled and routed; GI=false. Waiting up to {SpontaneousProofWindow.TotalSeconds:0}s for a spontaneous data-change report caused by a normal field/process change."); + + var receive = await auxiliary.ReceivePersistentReportMonitorSliceAsync( + monitorSession, + SpontaneousProofWindow, + pollDirectory: null, + pollReferences: null, + pollInterval: null, + triggerGeneralInterrogation: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + AppendWriteSteps(evidence, "G2.5-A receive", receive.WriteSteps); + evidence.Add($"G2.5-A receive: reports={receive.Reports.Count}; unrouted={auxiliary.UnroutedPersistentReportCount}; route={TextOrDash(auxiliary.LastReceiveRoutingSummary)}; GIrequested=false; result={receive.Message}"); + + 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}"); + if (!validation.IsSuccess) + continue; + + spontaneousProven = true; + associationHealthyAfterReport = auxiliary.IsMmsInitiated; + 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"); + break; + } + + if (!spontaneousProven) + evidence.Add("G2.5-A spontaneous dchg proof: success=false; no received frame proved exact RptID + DataSet + valid included member mapping with data-change reason only. RptEna acceptance or unrelated reports are not success."); + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"G2.5-A exception: {ex.GetType().Name}: {ex.Message}"); + } + finally + { + if (monitorSession is not null) + { + try + { + var stop = await auxiliary.StopPersistentReportMonitorAsync(monitorSession, CancellationToken.None).ConfigureAwait(false); + monitorCleanup = stop.IsSuccess; + AppendWriteSteps(evidence, "G2.5-A monitor cleanup", stop.WriteSteps); + evidence.Add($"G2.5-A 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($"G2.5-A monitor cleanup exception: {ex.GetType().Name}: {ex.Message}"); + } + } + else if (!activationAttempted) + { + monitorCleanup = true; + } + + if (fieldLease is not null) + { + try + { + var restore = await auxiliary.RestoreDynamicRcbCommissioningFieldsAsync(fieldLease, CancellationToken.None).ConfigureAwait(false); + fieldRestore = restore.IsSuccess; + AppendWriteSteps(evidence, "G2.5-A proof-field restore", restore.WriteSteps); + foreach (var line in restore.Evidence) + evidence.Add("G2.5-A proof-field restore: " + line); + evidence.Add($"G2.5-A proof-field restore: success={restore.IsSuccess}; result={restore.Message}"); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException) + { + fieldRestore = false; + evidence.Add($"G2.5-A proof-field restore exception: {ex.GetType().Name}: {ex.Message}"); + } + } + else + { + fieldRestore = true; + } + + await auxiliary.DisposeAsync().ConfigureAwait(false); + } + + var freshClosure = false; + if (plan is not null && activationAttempted) + { + freshClosure = await ProveFreshCleanupClosureAsync( + device, + rcbReference, + plan.DataSetReference, + evidence, + CancellationToken.None).ConfigureAwait(false); + } + else + { + freshClosure = monitorCleanup && fieldRestore; + } + + var success = activationProven && + spontaneousProven && + associationHealthyAfterReport && + monitorCleanup && + fieldRestore && + freshClosure; + evidence.Add($"G2.5-A combined result: activation={activationProven}; spontaneousDchg={spontaneousProven}; reportAssociationHealthy={associationHealthyAfterReport}; monitorCleanup={monitorCleanup}; proofFieldRestore={fieldRestore}; freshCleanupClosure={freshClosure}; success={success}"); + evidence.Add("G2.5-A safety: persisted profile remains InformationReportProven. Production automatic dynamic reporting remains OFF; this gate cannot set ProductionEligible."); + + return new DynamicReportSpontaneousDataChangeCommissioningResult + { + IsSuccess = success, + ActivationProven = activationProven, + SpontaneousDataChangeProven = spontaneousProven, + MonitorCleanupSucceeded = monitorCleanup, + ProofFieldRestoreSucceeded = fieldRestore, + FreshCleanupClosureSucceeded = freshClosure, + AssociationHealthyAfterReport = associationHealthyAfterReport, + 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 shown below; the persisted InformationReportProven profile is unchanged and production dynamic reporting remains OFF.", + Identity = identity, + InputProfile = profile, + RcbReference = rcbReference, + DataSetReference = plan?.DataSetReference ?? string.Empty, + ReportId = reportId, + MemberReferences = qualifiedReferences, + IncludedIndexes = includedIndexes, + IncludedMemberReferences = includedMembers, + Reasons = includedReasons, + ProfilePath = loaded.FilePath, + EvidenceLines = evidence + }; + } + + internal static bool IsPostLeaseUrcbSafeForDchg( + ArMms.MmsRcbAvailabilitySnapshot? snapshot, + string localTcpAddress, + out string reason) + { + if (snapshot is null) + { + reason = "Selected URCB is missing from post-lease readback."; + return false; + } + if (snapshot.Buffered) + { + reason = "G2.5-A permits URCB only."; + return false; + } + if (snapshot.DataSetProbeState != ArMms.MmsRcbDataSetProbeState.ReadSucceeded || !string.IsNullOrWhiteSpace(snapshot.DataSetReference)) + { + reason = "Post-lease DatSet must still be positively read and empty."; + return false; + } + if (ParseBool(snapshot.EnabledState) != false) + { + reason = $"Post-lease RptEna is not explicit false: {TextOrDash(snapshot.EnabledState)}"; + return false; + } + if (snapshot.Availability != ArMms.MmsRcbOperationalAvailability.UsedByCaller) + { + reason = $"Post-lease ownership is not UsedByCaller: {snapshot.Availability}."; + return false; + } + if (ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0) + { + reason = $"Post-lease reservation time is positive: {snapshot.ReservationTimeSeconds}."; + return false; + } + + if (HasOwner(snapshot.Owner) && + !ArMms.MmsRcbOwnerIdentity.MatchesLocalTcpAddress(snapshot.Owner, localTcpAddress, out var ownerReason)) + { + reason = "Post-lease Owner does not match the active G2.5-A MMS association: " + ownerReason; + return false; + } + + var triggers = ArMms.MmsReportControlFieldCodec.DecodeTriggerOptions(snapshot.TriggerOptions); + if (!triggers.DataChange || triggers.GeneralInterrogation || triggers.Integrity || triggers.QualityChange || triggers.DataUpdate) + { + reason = $"TrgOps is not strict dchg-only: dchg={triggers.DataChange}, qchg={triggers.QualityChange}, dupd={triggers.DataUpdate}, integrity={triggers.Integrity}, GI={triggers.GeneralInterrogation}."; + return false; + } + + var fields = ArMms.MmsReportControlFieldCodec.DecodeOptionalFields(snapshot.OptionalFields); + if (!fields.ReasonForInclusion || !fields.DataSetName || string.IsNullOrWhiteSpace(snapshot.ReportId)) + { + reason = $"Strict report identity fields missing: RptID={TextOrDash(snapshot.ReportId)}, reason={fields.ReasonForInclusion}, dataSetName={fields.DataSetName}."; + return false; + } + + reason = "Caller-owned post-lease URCB is strict dchg-only with GI/integrity/qchg/dupd disabled and reason-for-inclusion + DataSet-name enabled."; + return true; + } + + internal static DynamicReportSpontaneousDataChangeValidation ValidateSpontaneousDataChangeFrame( + ArMms.MmsReportFrame frame, + string expectedReportId, + string expectedDataSetReference, + IReadOnlyList qualifiedReferences) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(qualifiedReferences); + + if (frame.DecoderMode.Equals("rejected-unmapped", StringComparison.OrdinalIgnoreCase)) + return Invalid("Report decoder quarantined the frame as unmapped."); + if (string.IsNullOrWhiteSpace(expectedReportId) || !frame.Header.ReportId.Trim().Equals(expectedReportId.Trim(), StringComparison.OrdinalIgnoreCase)) + return Invalid($"RptID mismatch. expected={TextOrDash(expectedReportId)}, actual={TextOrDash(frame.Header.ReportId)}"); + if (string.IsNullOrWhiteSpace(frame.Header.DataSetReference) || !SameReference(frame.Header.DataSetReference, expectedDataSetReference)) + return Invalid($"DataSet mismatch. expected={expectedDataSetReference}, actual={TextOrDash(frame.Header.DataSetReference)}"); + if (qualifiedReferences.Count == 0 || frame.Values.Count == 0) + return Invalid("Spontaneous dchg proof requires at least one included successful DataSet member."); + if (frame.IncludedDataSetIndexes.Count != frame.Values.Count) + return Invalid($"Included-index/value count mismatch: indexes={frame.IncludedDataSetIndexes.Count}, values={frame.Values.Count}."); + if (frame.IncludedDataSetIndexes.Distinct().Count() != frame.IncludedDataSetIndexes.Count) + return Invalid("Included DataSet indexes contain duplicates."); + + var included = new List(); + var members = new List(); + var reasons = new List(); + for (var offset = 0; offset < frame.Values.Count; offset++) + { + var value = frame.Values[offset]; + var dataSetIndex = frame.IncludedDataSetIndexes[offset]; + if (dataSetIndex < 0 || dataSetIndex >= qualifiedReferences.Count) + return Invalid($"Included DataSet index {dataSetIndex} is outside 0..{qualifiedReferences.Count - 1}."); + if (value.Index != dataSetIndex) + return Invalid($"Mapped value index mismatch at offset {offset}: included={dataSetIndex}, value.Index={value.Index}."); + if (value.Member is null || !SameReference(value.Member.MmsReference, qualifiedReferences[dataSetIndex])) + return Invalid($"Mapped member mismatch at DataSet index {dataSetIndex}: expected={qualifiedReferences[dataSetIndex]}, actual={value.Member?.MmsReference ?? ""}."); + if (value.Value is null || value.FailureCode.HasValue) + return Invalid($"Included member {qualifiedReferences[dataSetIndex]} has no successful process value (failure={value.FailureCode?.ToString() ?? "none"})."); + if (!string.IsNullOrWhiteSpace(value.DataReference) && + !SameReference(value.DataReference, qualifiedReferences[dataSetIndex]) && + !SameReference(value.DataReference, value.Member.UserReference)) + { + return Invalid($"DataRef mismatch at DataSet index {dataSetIndex}: actual={value.DataReference}."); + } + + var valueReasons = value.ReasonForInclusion + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (!valueReasons.Contains("data-change", StringComparer.OrdinalIgnoreCase)) + return Invalid($"Included member {qualifiedReferences[dataSetIndex]} does not carry reason-for-inclusion=data-change; reasons={TextOrDash(string.Join(",", valueReasons))}."); + if (valueReasons.Any(item => + item.Equals("general-interrogation", StringComparison.OrdinalIgnoreCase) || + item.Equals("integrity", StringComparison.OrdinalIgnoreCase) || + item.Equals("quality-change", StringComparison.OrdinalIgnoreCase) || + item.Equals("data-update", StringComparison.OrdinalIgnoreCase))) + { + return Invalid($"Included member {qualifiedReferences[dataSetIndex]} carries a non-dchg reason under a dchg-only lease: {string.Join(",", valueReasons)}."); + } + + included.Add(dataSetIndex); + members.Add(qualifiedReferences[dataSetIndex]); + reasons.AddRange(valueReasons); + } + + return new DynamicReportSpontaneousDataChangeValidation + { + IsSuccess = true, + IncludedIndexes = included.ToArray(), + IncludedMemberReferences = members.ToArray(), + Reasons = reasons.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + Reason = $"Actual spontaneous InformationReport verified with dchg-only reasons for {included.Count} included member(s); GI was not requested." + }; + } + + private async Task ProveFreshCleanupClosureAsync( + Iec61850MonitorDevice device, + string rcbReference, + string temporaryDataSetReference, + ICollection evidence, + CancellationToken cancellationToken) + { + await using var fresh = new ArMms.MmsClientSession(); + try + { + await fresh.ConnectAsync(device.IpAddress, device.Port, AuxiliaryAssociationTimeout, cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.5-A fresh cleanup association ready: state={fresh.State}; localTcpAddress={TextOrDash(fresh.LocalTcpAddress)}"); + 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("G2.5-A fresh cleanup: exact URCB not found."); + return false; + } + + var oneRcb = new ArMms.MmsReportInventory(); + oneRcb.ReportControls.Add(rcb); + var availability = await fresh.CheckReportControlAvailabilityAsync( + oneRcb, + discovery.IedDirectory, + new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, + cancellationToken).ConfigureAwait(false); + var snapshot = availability.ReportControls.SingleOrDefault(); + if (snapshot is not null) + evidence.Add($"G2.5-A fresh cleanup URCB: availability={snapshot.Availability}; probe={snapshot.DataSetProbeState}; DatSet={TextOrDash(snapshot.DataSetReference)}; RptEna={TextOrDash(snapshot.EnabledState)}; Resv={TextOrDash(snapshot.ReservationState)}; Owner={TextOrDash(snapshot.Owner)}; TrgOps={TextOrDash(snapshot.TriggerOptions)}; OptFlds={TextOrDash(snapshot.OptionalFields)}"); + + var nameAbsent = DynamicReportCleanupClosureCommissioningService.IsTemporaryDataSetAbsentFromNameList( + discovery.Snapshot, + temporaryDataSetReference, + out var nameReason); + evidence.Add("G2.5-A fresh cleanup namespace: " + nameReason); + + var directory = await fresh.GetDataSetDirectoryAsync(temporaryDataSetReference, discovery.IedDirectory, cancellationToken).ConfigureAwait(false); + var directoryAbsent = !directory.IsSuccess; + evidence.Add($"G2.5-A fresh cleanup DataSet directory: absent={directoryAbsent}; success={directory.IsSuccess}; members={directory.Members.Count}; result={directory.Message}"); + + var closed = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed( + snapshot, + nameAbsent, + directoryAbsent, + fresh.IsMmsInitiated, + out var closureReason); + evidence.Add("G2.5-A fresh cleanup evaluation: " + closureReason); + return closed; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"G2.5-A 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 DynamicReportSpontaneousDataChangeValidation Invalid(string reason) + => new() { IsSuccess = false, Reason = reason }; + + private static bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual) + { + var left = expected.ToArray(); + var right = actual.ToArray(); + return left.Length == right.Length && left.Select(NormalizeReference).SequenceEqual(right.Select(NormalizeReference), 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? value) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0 || text == "-") return null; + if (bool.TryParse(text, out var parsed)) return parsed; + if (text is "1" or "01" || text.Equals("yes", StringComparison.OrdinalIgnoreCase) || text.Equals("on", StringComparison.OrdinalIgnoreCase)) return true; + if (text is "0" or "00" || text.Equals("no", StringComparison.OrdinalIgnoreCase) || text.Equals("off", StringComparison.OrdinalIgnoreCase)) return false; + return null; + } + + private static ulong? ParseUnsigned(string? value) + => ulong.TryParse((value ?? string.Empty).Trim(), out var parsed) ? parsed : null; + + private static bool HasOwner(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0 || text == "-" || text == "[]" || text.Equals("null", StringComparison.OrdinalIgnoreCase)) return false; + var compact = text.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace(":", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal); + return compact.Length > 0 && compact.Any(character => character != '0'); + } + + private static DynamicReportSpontaneousDataChangeCommissioningResult Blocked( + string summary, + IReadOnlyList evidence, + ArMms.MmsDynamicReportIedIdentity? identity = null, + string profilePath = "", + ArMms.MmsDynamicReportQualificationProfile? profile = null) + => new() + { + IsBlocked = true, + Summary = summary, + Identity = identity, + InputProfile = profile, + ProfilePath = profilePath, + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportSpontaneousDataChangeCommissioningResult Failed( + string summary, + IReadOnlyList evidence, + ArMms.MmsDynamicReportIedIdentity identity, + ArMms.MmsDynamicReportQualificationProfile profile, + string profilePath, + string rcbReference, + IReadOnlyList memberReferences, + string dataSetReference = "", + bool monitorCleanupSucceeded = false) + => new() + { + IsSuccess = false, + Summary = summary, + Identity = identity, + InputProfile = profile, + RcbReference = rcbReference, + DataSetReference = dataSetReference, + MemberReferences = memberReferences.ToArray(), + MonitorCleanupSucceeded = monitorCleanupSucceeded, + ProfilePath = profilePath, + EvidenceLines = evidence.ToArray() + }; + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); +} From 270f22a7c673ce9855d7d388d4382458311d18a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 09:09:03 +0700 Subject: [PATCH 02/12] G2.5-A: add spontaneous dchg evidence window --- ...micReportQualificationResultWindow.G25A.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 DynamicReportQualificationResultWindow.G25A.cs diff --git a/DynamicReportQualificationResultWindow.G25A.cs b/DynamicReportQualificationResultWindow.G25A.cs new file mode 100644 index 00000000..a17f3885 --- /dev/null +++ b/DynamicReportQualificationResultWindow.G25A.cs @@ -0,0 +1,99 @@ +using System.Text; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +internal partial class DynamicReportQualificationResultWindow +{ + internal DynamicReportQualificationResultWindow(DynamicReportSpontaneousDataChangeCommissioningResult result) + { + ArgumentNullException.ThrowIfNull(result); + InitializeComponent(); + + Title = "G2.5-A Spontaneous dchg Proof Evidence"; + HeaderText.Text = "G2.5-A Spontaneous dchg Proof"; + SummaryText.Text = result.Summary; + StateText.Text = result.IsSuccess + ? "Spontaneous dchg Proven" + : result.IsBlocked + ? "Blocked" + : "dchg proof not proven"; + EvidenceTextBox.Text = BuildG25AEvidence(result); + + if (result.IsSuccess) + SetPassBadge(); + } + + private static string BuildG25AEvidence(DynamicReportSpontaneousDataChangeCommissioningResult result) + { + var builder = new StringBuilder(); + builder.AppendLine("ARSAS G2.5-A SPONTANEOUS DCHG INFORMATIONREPORT EVIDENCE"); + builder.AppendLine(new string('=', 76)); + builder.AppendLine($"Result: {result.Summary}"); + builder.AppendLine($"Blocked: {result.IsBlocked}"); + builder.AppendLine($"G2.5-A success: {result.IsSuccess}"); + + if (result.Identity is not null) + { + builder.AppendLine(); + builder.AppendLine("IED IDENTITY"); + builder.AppendLine($"Stable identity: {result.Identity.StableIdentityKey}"); + builder.AppendLine($"Model fingerprint: {result.Identity.ModelFingerprint}"); + builder.AppendLine($"Model: {result.Identity.Model}"); + builder.AppendLine($"Firmware: {result.Identity.FirmwareRevision}"); + builder.AppendLine($"Profile revision: {result.Identity.ProfileRevision}"); + } + + builder.AppendLine(); + builder.AppendLine("G2.5-A TARGET"); + builder.AppendLine($"Input profile state: {result.InputProfile?.State.ToString() ?? "-"}"); + builder.AppendLine($"URCB: {G25ATextOrDash(result.RcbReference)}"); + builder.AppendLine($"Temporary DataSet: {G25ATextOrDash(result.DataSetReference)}"); + builder.AppendLine($"RptID: {G25ATextOrDash(result.ReportId)}"); + builder.AppendLine($"Qualified members: {result.MemberReferences.Count}"); + foreach (var member in result.MemberReferences) + builder.AppendLine(" " + member); + + builder.AppendLine(); + builder.AppendLine("TRIGGER CONTRACT"); + builder.AppendLine("TrgOps temporary: dchg ONLY"); + builder.AppendLine("Canonical TrgOps raw target: 0240"); + builder.AppendLine("OptFlds temporary: reason-for-inclusion + data-set-name"); + builder.AppendLine("Canonical OptFlds raw target: 061800"); + builder.AppendLine("GI requested: False"); + builder.AppendLine("Integrity/qchg/dupd requested: False"); + + builder.AppendLine(); + builder.AppendLine("PROOF RESULT"); + builder.AppendLine($"Activation proven: {result.ActivationProven}"); + builder.AppendLine($"Spontaneous data-change proven: {result.SpontaneousDataChangeProven}"); + builder.AppendLine($"Association healthy after report: {result.AssociationHealthyAfterReport}"); + builder.AppendLine($"Included indexes: [{string.Join(",", result.IncludedIndexes)}]"); + builder.AppendLine($"Reasons: [{string.Join(",", result.Reasons)}]"); + builder.AppendLine("Included members:"); + foreach (var member in result.IncludedMemberReferences) + builder.AppendLine(" " + member); + + builder.AppendLine(); + builder.AppendLine("CLEANUP CLOSURE"); + builder.AppendLine($"Monitor cleanup: {result.MonitorCleanupSucceeded}"); + builder.AppendLine($"Proof-field restore: {result.ProofFieldRestoreSucceeded}"); + builder.AppendLine($"Fresh-association cleanup closure: {result.FreshCleanupClosureSucceeded}"); + + builder.AppendLine(); + builder.AppendLine("WIRE / SAFETY EVIDENCE"); + foreach (var line in result.EvidenceLines) + builder.AppendLine(line); + + builder.AppendLine(); + builder.AppendLine("SAFETY STATE"); + builder.AppendLine("G2.5-A sends NO GI and accepts only an actual spontaneous data-change report as proof."); + builder.AppendLine("G2.5-A does not alter the persisted InformationReportProven profile."); + builder.AppendLine("G2.5-A PASS != ProductionEligible."); + builder.AppendLine("Production automatic dynamic reporting remains OFF until later G2.5/G2.6 gates pass."); + return builder.ToString(); + } + + private static string G25ATextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); +} From b79d7a825681f56f2254e57a6ac0a9172433eb12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 09:09:45 +0700 Subject: [PATCH 03/12] G2.5-A: add Ctrl+Shift+D spontaneous dchg field gate --- DynamicReportQualificationUiBehavior.cs | 39 ++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/DynamicReportQualificationUiBehavior.cs b/DynamicReportQualificationUiBehavior.cs index ef8e337a..977d8be5 100644 --- a/DynamicReportQualificationUiBehavior.cs +++ b/DynamicReportQualificationUiBehavior.cs @@ -26,7 +26,7 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) { if (sender is not MainWindow window || Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) || - (e.Key != Key.Q && e.Key != Key.R && e.Key != Key.T && e.Key != Key.O && e.Key != Key.C)) + (e.Key != Key.Q && e.Key != Key.R && e.Key != Key.T && e.Key != Key.O && e.Key != Key.C && e.Key != Key.D)) return; e.Handled = true; @@ -64,6 +64,8 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) await RunP1OptionalFieldsProbeAsync(window, device); else if (e.Key == Key.C) await RunG24CleanupClosureAsync(window, device); + else if (e.Key == Key.D) + await RunG25ASpontaneousDataChangeAsync(window, device); else await RunG24Async(window, device); } @@ -211,6 +213,41 @@ private static async Task RunG24CleanupClosureAsync(MainWindow window, Models.Ie evidenceWindow.ShowDialog(); } + private static async Task RunG25ASpontaneousDataChangeAsync(MainWindow window, Models.Iec61850MonitorDevice device) + { + var answer = MessageBox.Show( + window, + $"Run G2.5-A spontaneous dchg proof for {device.Name} ({device.EndpointText})?\n\n" + + "ACTIVE COMMISSIONING — ONE URCB / NO GI\n\n" + + "ARSAS will use the stored InformationReportProven profile and EXACT G2.4-proven URCB + 8-member set. It will capture original report-control fields, temporarily set TrgOps to dchg ONLY (canonical 0240), request reason-for-inclusion + data-set-name (061800), create one temporary DataSet, enable the URCB, and arm report routing.\n\n" + + "ARSAS WILL NOT SEND GI. After the status bar says 'G2.5-A ARMED — NO GI', cause ONE normal physical/process status change that affects one of the 8 proven points. You may use an already-tested normal OPEN/CLOSE operation if it naturally changes one of those status points, or another safe field stimulus. Do NOT manually edit any RCB or DataSet.\n\n" + + "PASS requires an ACTUAL spontaneous InformationReport with exact RptID/DataSet, valid included member mapping, and reason-for-inclusion=data-change. GI/integrity/quality-change/data-update reports are explicitly rejected as G2.5-A proof.\n\n" + + "After the bounded wait, ARSAS disables/cleans the monitor, restores exact proof fields, closes the association, then opens a NEW read-only association and requires DatSet empty, RptEna=false, Resv=false, Owner empty and temporary DataSet absent. The persisted InformationReportProven profile is NOT modified and production dynamic reporting remains OFF.\n\n" + + "Continue?", + "G2.5-A Spontaneous dchg Proof", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + + window.LastStatusText = $"G2.5-A: preparing exact G2.4-proven URCB on an isolated auxiliary association to {device.Name}…"; + var progress = new Progress(text => window.LastStatusText = text); + var service = new DynamicReportSpontaneousDataChangeCommissioningService(); + var result = await service.RunAsync( + device, + device.Signals.ToArray(), + progress, + CancellationToken.None); + + window.LastStatusText = result.Summary; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) + { + Owner = window + }; + evidenceWindow.ShowDialog(); + } + private static async Task RunG24Async(MainWindow window, Models.Iec61850MonitorDevice device) { var answer = MessageBox.Show( From 8fb7be89a6a8b0382074b7af669684066e95323e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 09:13:06 +0700 Subject: [PATCH 04/12] G2.5-A: regress dchg-only no-GI proof and cleanup --- ...eousDataChangeCommissioningServiceTests.cs | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 tests/ARSAS.Tests/DynamicReportSpontaneousDataChangeCommissioningServiceTests.cs diff --git a/tests/ARSAS.Tests/DynamicReportSpontaneousDataChangeCommissioningServiceTests.cs b/tests/ARSAS.Tests/DynamicReportSpontaneousDataChangeCommissioningServiceTests.cs new file mode 100644 index 00000000..84178918 --- /dev/null +++ b/tests/ARSAS.Tests/DynamicReportSpontaneousDataChangeCommissioningServiceTests.cs @@ -0,0 +1,300 @@ +using ArIED61850Tester.Services; +using ArMms = AR.Iec61850.Mms; + +namespace ARSAS.Tests; + +public sealed class DynamicReportSpontaneousDataChangeCommissioningServiceTests +{ + [Fact] + public void G25A_UsesDchgOnlyCanonicalTargetAndNoGiReceive() + { + Assert.Equal("dchg", DynamicReportSpontaneousDataChangeCommissioningService.TemporaryTriggerOptions); + Assert.Equal("0240", DynamicReportSpontaneousDataChangeCommissioningService.ExpectedCanonicalTriggerRaw); + Assert.Equal("reason-for-inclusion data-set-name", DynamicReportSpontaneousDataChangeCommissioningService.TemporaryOptionalFields); + Assert.Equal("061800", DynamicReportSpontaneousDataChangeCommissioningService.ExpectedCanonicalOptionalFieldsRaw); + + var source = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "DynamicReportSpontaneousDataChangeCommissioningService.cs")); + Assert.Contains("triggerGeneralInterrogation: false", source, StringComparison.Ordinal); + Assert.DoesNotContain("triggerGeneralInterrogation: true", source, StringComparison.Ordinal); + Assert.Contains("GIrequested=false", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("AR_G25A_", source, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + } + + [Fact] + public void PostLeaseGate_AcceptsStrictDchgOnlyCallerOwnedState() + { + var snapshot = Snapshot("0240", "061800", owner: "C0A851F0"); + + var ok = DynamicReportSpontaneousDataChangeCommissioningService.IsPostLeaseUrcbSafeForDchg( + snapshot, + "192.168.81.240", + out var reason); + + Assert.True(ok, reason); + Assert.Contains("dchg-only", reason, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("0244")] // dchg + GI + [InlineData("0248")] // dchg + integrity + [InlineData("0260")] // dchg + qchg + [InlineData("0250")] // dchg + dupd + [InlineData("0204")] // GI only + public void PostLeaseGate_RejectsAnyNonDchgOnlyTriggerShape(string trgOps) + { + var ok = DynamicReportSpontaneousDataChangeCommissioningService.IsPostLeaseUrcbSafeForDchg( + Snapshot(trgOps, "061800", owner: "C0A851F0"), + "192.168.81.240", + out var reason); + + Assert.False(ok); + Assert.Contains("not strict dchg-only", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void PostLeaseGate_RejectsWrongOwnerOrExternalUse() + { + var wrongOwner = Snapshot("0240", "061800", owner: "C0A851F0"); + var ok = DynamicReportSpontaneousDataChangeCommissioningService.IsPostLeaseUrcbSafeForDchg( + wrongOwner, + "192.168.81.241", + out var reason); + Assert.False(ok); + Assert.Contains("Owner does not match", reason, StringComparison.OrdinalIgnoreCase); + + var external = Snapshot("0240", "061800", owner: "C0A851F0", availability: ArMms.MmsRcbOperationalAvailability.InUse); + ok = DynamicReportSpontaneousDataChangeCommissioningService.IsPostLeaseUrcbSafeForDchg( + external, + "192.168.81.240", + out reason); + Assert.False(ok); + Assert.Contains("UsedByCaller", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SpontaneousGate_AcceptsPartialIncludedMemberWithDataChangeReason() + { + var refs = Refs(); + var members = Members(); + var frame = Frame( + "R1", + "LD0/LLN0.AR_G25A_TEST", + [members[1]], + [1], + ["data-change"]); + + var result = DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame( + frame, + "R1", + "LD0/LLN0.AR_G25A_TEST", + refs); + + Assert.True(result.IsSuccess, result.Reason); + Assert.Equal([1], result.IncludedIndexes); + Assert.Equal([refs[1]], result.IncludedMemberReferences); + Assert.Contains("data-change", result.Reasons, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void SpontaneousGate_AcceptsMultipleCorrectDataChangeMembersWithoutRequiringEightOfEight() + { + var refs = Refs(); + var members = Members(); + var frame = Frame( + "R1", + "LD0/LLN0.AR_G25A_TEST", + [members[0], members[2]], + [0, 2], + ["data-change"]); + + var result = DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame( + frame, + "R1", + "LD0/LLN0.AR_G25A_TEST", + refs); + + Assert.True(result.IsSuccess, result.Reason); + Assert.Equal([0, 2], result.IncludedIndexes); + } + + [Theory] + [InlineData("general-interrogation")] + [InlineData("integrity")] + [InlineData("quality-change")] + [InlineData("data-update")] + public void SpontaneousGate_RejectsNonDchgReasons(string reasonForInclusion) + { + var refs = Refs(); + var members = Members(); + var frame = Frame("R1", "LD0/LLN0.AR_G25A_TEST", [members[0]], [0], [reasonForInclusion]); + + var result = DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame( + frame, + "R1", + "LD0/LLN0.AR_G25A_TEST", + refs); + + Assert.False(result.IsSuccess); + Assert.Contains("does not carry", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SpontaneousGate_RejectsMixedDataChangeAndGiReason() + { + var refs = Refs(); + var members = Members(); + var frame = Frame("R1", "LD0/LLN0.AR_G25A_TEST", [members[0]], [0], ["data-change", "general-interrogation"]); + + var result = DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame( + frame, + "R1", + "LD0/LLN0.AR_G25A_TEST", + refs); + + Assert.False(result.IsSuccess); + Assert.Contains("non-dchg reason", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SpontaneousGate_RejectsWrongIdentityIndexMemberAndFailedValue() + { + var refs = Refs(); + var members = Members(); + var valid = Frame("R1", "LD0/LLN0.AR_G25A_TEST", [members[1]], [1], ["data-change"]); + + Assert.False(DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame(valid, "OTHER", "LD0/LLN0.AR_G25A_TEST", refs).IsSuccess); + Assert.False(DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame(valid, "R1", "LD0/LLN0.OTHER", refs).IsSuccess); + + var wrongIndex = Frame("R1", "LD0/LLN0.AR_G25A_TEST", [members[0]], [1], ["data-change"]); + Assert.False(DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame(wrongIndex, "R1", "LD0/LLN0.AR_G25A_TEST", refs).IsSuccess); + + var failed = new ArMms.MmsReportFrame + { + Header = new ArMms.MmsReportHeader { ReportId = "R1", DataSetReference = "LD0/LLN0.AR_G25A_TEST" }, + DecoderMode = "optflds-driven", + IncludedDataSetIndexes = [0], + Values = + [ + new ArMms.MmsReportValue + { + Index = 0, + Member = members[0], + FailureCode = 3, + ReasonForInclusion = ["data-change"] + } + ] + }; + Assert.False(DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame(failed, "R1", "LD0/LLN0.AR_G25A_TEST", refs).IsSuccess); + } + + [Fact] + public void G25A_SourceRequiresFreshCleanupClosureAndDoesNotTouchProductionPolicy() + { + var service = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "DynamicReportSpontaneousDataChangeCommissioningService.cs")); + var ui = File.ReadAllText(Path.Combine(RepoRoot(), "DynamicReportQualificationUiBehavior.cs")); + var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + + Assert.Contains("ProveFreshCleanupClosureAsync", service, StringComparison.Ordinal); + Assert.Contains("IsFreshCleanupClosed", service, StringComparison.Ordinal); + Assert.Contains("IsTemporaryDataSetAbsentFromNameList", service, StringComparison.Ordinal); + Assert.Contains("FreshCleanupClosureSucceeded", service, StringComparison.Ordinal); + Assert.Contains("e.Key != Key.D", ui, StringComparison.Ordinal); + Assert.Contains("RunG25ASpontaneousDataChangeAsync", ui, StringComparison.Ordinal); + Assert.Contains("G2.5-A ARMED — NO GI", ui, StringComparison.Ordinal); + Assert.Contains("Do NOT manually edit any RCB or DataSet", ui, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal); + } + + private static string[] Refs() + => + [ + "LD0/GGIO1$ST$Ind1$stVal", + "LD0/GGIO1$ST$Ind2$stVal", + "LD0/GGIO1$ST$Ind3$stVal" + ]; + + private static ArMms.MmsDataSetDirectoryMember[] Members() + => + [ + Member("LD0", "GGIO1$ST$Ind1$stVal", "Ind1.stVal"), + Member("LD0", "GGIO1$ST$Ind2$stVal", "Ind2.stVal"), + Member("LD0", "GGIO1$ST$Ind3$stVal", "Ind3.stVal") + ]; + + private static ArMms.MmsDataSetDirectoryMember Member(string domain, string item, string path) + => new() + { + Domain = domain, + MmsItemName = item, + UserReference = $"{domain}/{item}", + FunctionalConstraint = "ST", + LogicalNode = "GGIO1", + DataObjectPath = path + }; + + private static ArMms.MmsReportFrame Frame( + string reportId, + string dataSet, + IReadOnlyList members, + IReadOnlyList included, + IReadOnlyList reasons) + => new() + { + ReceivedAt = DateTimeOffset.UtcNow, + Header = new ArMms.MmsReportHeader + { + ReportId = reportId, + DataSetReference = dataSet + }, + DecoderMode = "optflds-driven", + IncludedDataSetIndexes = included.ToArray(), + Values = members.Select((member, index) => new ArMms.MmsReportValue + { + Index = included[index], + Member = member, + Value = ArMms.MmsDataValue.Boolean(true), + ReasonForInclusion = reasons + }).ToArray() + }; + + private static ArMms.MmsRcbAvailabilitySnapshot Snapshot( + string trgOps, + string optFlds, + string owner = "", + ArMms.MmsRcbOperationalAvailability availability = ArMms.MmsRcbOperationalAvailability.UsedByCaller) + => new() + { + Reference = "LD0/LLN0.RP01", + Domain = "LD0", + LogicalNode = "LLN0", + Name = "RP01", + Buffered = false, + DataSetReference = string.Empty, + DataSetProbeState = ArMms.MmsRcbDataSetProbeState.ReadSucceeded, + ReportId = "R1", + TriggerOptions = trgOps, + OptionalFields = optFlds, + EnabledState = "false", + ReservationState = "true", + ReservationTimeSeconds = "0", + Owner = owner, + Availability = availability, + Confidence = ArMms.MmsRcbAvailabilityConfidence.Exact, + Attributes = ["DatSet", "RptID", "RptEna", "Resv", "TrgOps", "OptFlds", "GI"] + }; + + private static string RepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj"))) + return current.FullName; + current = current.Parent; + } + throw new DirectoryNotFoundException("ARSAS repository root not found."); + } +} From 16de2018bbb327d93e811df7ebe21cfcd2f80807 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 09:17:51 +0700 Subject: [PATCH 05/12] G2.5-A: harden no-GI failure cleanup lifecycle --- ...ontaneousDataChangeCommissioningService.cs | 476 +++++------------- 1 file changed, 129 insertions(+), 347 deletions(-) diff --git a/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs b/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs index 7f855f43..e97c1f18 100644 --- a/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs +++ b/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs @@ -36,16 +36,6 @@ internal sealed class DynamicReportSpontaneousDataChangeCommissioningResult public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); } -/// -/// G2.5-A explicit commissioning gate for one spontaneous dchg InformationReport. -/// -/// This gate consumes only an identity-compatible InformationReportProven profile, -/// reuses the exact G2.4-proven URCB and exact eight-member set, enables dchg ONLY, -/// never requests GI, and waits for a real spontaneous process/status change. It does -/// not change the persisted profile or production monitoring policy. PASS also requires -/// monitor cleanup, exact proof-field restore, and a second fresh-association read-only -/// cleanup closure. -/// internal sealed class DynamicReportSpontaneousDataChangeCommissioningService { private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10); @@ -84,7 +74,6 @@ public async Task RunAsyn } evidence.Add($"G2.5-A identity stableKey={identity.StableIdentityKey}; fingerprint={identity.ModelFingerprint}; profileRevision={TextOrDash(identity.ProfileRevision)}"); - var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); evidence.Add($"G2.5-A persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; reason={loaded.Reason}"); if (!loaded.IsValid || loaded.Profile is null) @@ -116,23 +105,24 @@ profile.AcceptedEnvelope is null || evidence.Add($"G2.5-A profile gate: state={profile.State}; rcb={rcbReference}; members={qualifiedReferences.Length}; temporaryTrgOps={TemporaryTriggerOptions}; expectedTrgOpsRaw={ExpectedCanonicalTriggerRaw}; temporaryOptFlds={TemporaryOptionalFields}; expectedOptFldsRaw={ExpectedCanonicalOptionalFieldsRaw}"); evidence.Add("G2.5-A exact members: " + string.Join(" | ", qualifiedReferences)); evidence.Add("G2.5-A trigger contract: dchg ONLY. GI=false, integrity=false, qchg=false, dupd=false. No GI request is sent at monitor start or receive time."); - evidence.Add("G2.5-A profile contract: the persisted InformationReportProven profile is READ ONLY and will not be saved, downgraded, or advanced by this gate."); + evidence.Add("G2.5-A profile contract: persisted InformationReportProven is READ ONLY; this action never saves, downgrades, advances, or marks ProductionEligible."); var auxiliary = new ArMms.MmsClientSession(); ArMms.MmsDynamicRcbCommissioningFieldLease? fieldLease = null; ArMms.MmsPersistentReportMonitorSession? monitorSession = null; ArMms.MmsReportSubscriptionPlan? plan = null; - ArMms.MmsReportControlCandidate? selectedRcb = null; var activationProven = false; var spontaneousProven = false; var associationHealthyAfterReport = false; - var monitorCleanup = false; - var fieldRestore = false; + var monitorCleanup = true; + var fieldRestore = true; + var freshClosure = true; + var dynamicAttempted = false; var includedIndexes = Array.Empty(); var includedMembers = Array.Empty(); var includedReasons = Array.Empty(); var reportId = string.Empty; - var activationAttempted = false; + var failureSummary = string.Empty; try { @@ -155,24 +145,20 @@ profile.AcceptedEnvelope is null || out var exactReason)) { evidence.Add("G2.5-A exact member revalidation failed: " + exactReason); - return Failed("The exact G2.4-proven member set no longer maps to the live model. No RCB mutation was attempted.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); + return FailedBeforeMutation("The exact G2.4-proven member set no longer maps to the live model.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); } foreach (var point in exactPoints) { - cancellationToken.ThrowIfCancellationRequested(); var read = await auxiliary.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false); evidence.Add($"G2.5-A direct-read {point.MmsReference}: success={read.IsSuccess}; result={read.Message}"); if (!read.IsSuccess || !auxiliary.IsMmsInitiated) - return Failed("An exact G2.4-proven member failed fresh direct MMS validation. No RCB mutation was attempted.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); + return FailedBeforeMutation("An exact G2.4-proven member failed fresh direct MMS validation.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); } - selectedRcb = discovery.ReportInventory.ReportControls.FirstOrDefault(candidate => SameReference(candidate.Reference, rcbReference)); + var selectedRcb = discovery.ReportInventory.ReportControls.FirstOrDefault(candidate => SameReference(candidate.Reference, rcbReference)); if (selectedRcb is null || selectedRcb.Buffered) - { - evidence.Add("G2.5-A exact URCB lookup failed or resolved to a buffered RCB."); - return Failed("The exact G2.4-proven URCB is not available in fresh discovery. No mutation was attempted.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); - } + return FailedBeforeMutation("The exact G2.4-proven URCB is absent or no longer an URCB.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); var oneRcb = new ArMms.MmsReportInventory(); oneRcb.ReportControls.Add(selectedRcb); @@ -186,15 +172,16 @@ profile.AcceptedEnvelope is null || if (preLeaseSnapshot is not null) evidence.Add($"G2.5-A pre-lease URCB: availability={preLeaseSnapshot.Availability}; probe={preLeaseSnapshot.DataSetProbeState}; DatSet={TextOrDash(preLeaseSnapshot.DataSetReference)}; RptEna={TextOrDash(preLeaseSnapshot.EnabledState)}; Resv={TextOrDash(preLeaseSnapshot.ReservationState)}; Owner={TextOrDash(preLeaseSnapshot.Owner)}; RptID={TextOrDash(preLeaseSnapshot.ReportId)}; TrgOps={TextOrDash(preLeaseSnapshot.TriggerOptions)}; OptFlds={TextOrDash(preLeaseSnapshot.OptionalFields)}"); - if (preLeaseSnapshot is null || - !DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(preLeaseSnapshot, out var preLeaseReason)) + var preLeaseReason = "snapshot missing"; + var preLeaseSafe = preLeaseSnapshot is not null && + DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(preLeaseSnapshot, out preLeaseReason); + if (!preLeaseSafe) { - evidence.Add("G2.5-A pre-lease URCB rejected: " + (preLeaseSnapshot is null ? "snapshot missing" : preLeaseReason)); - return Failed("The exact G2.4-proven URCB is not freshly proven free. No proof-field mutation was attempted.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); + evidence.Add("G2.5-A pre-lease URCB rejected: " + preLeaseReason); + return FailedBeforeMutation("The exact G2.4-proven URCB is not freshly proven free.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences); } - ApplyFreshSnapshot(selectedRcb, preLeaseSnapshot); - + ApplyFreshSnapshot(selectedRcb, preLeaseSnapshot!); var fieldPrepare = await auxiliary.PrepareDynamicRcbCommissioningFieldsAsync( selectedRcb, TemporaryTriggerOptions, @@ -209,54 +196,46 @@ profile.AcceptedEnvelope is null || { return new DynamicReportSpontaneousDataChangeCommissioningResult { - IsSuccess = false, Summary = fieldPrepare.CleanupSucceeded - ? "G2.5-A dchg-only proof-field preparation failed, but exact rollback passed. The InformationReportProven profile is unchanged." - : "G2.5-A proof-field preparation failed and rollback was not fully proven. Inspect the URCB from a fresh association before retry.", + ? "G2.5-A dchg-only proof-field preparation failed, but engine rollback passed. Profile unchanged." + : "G2.5-A proof-field preparation failed and rollback was not fully proven. Fresh inspection is required before retry.", Identity = identity, InputProfile = profile, RcbReference = rcbReference, MemberReferences = qualifiedReferences, ProofFieldRestoreSucceeded = fieldPrepare.CleanupSucceeded, ProfilePath = loaded.FilePath, - EvidenceLines = evidence + EvidenceLines = evidence.ToArray() }; } fieldLease = fieldPrepare.Lease; evidence.Add($"G2.5-A proof-field lease ACTIVE: originalTrgOps={fieldLease.OriginalTriggerOptionsText}; originalOptFlds={fieldLease.OriginalOptionalFieldsText}; temporaryTrgOps=dchg-only/{ExpectedCanonicalTriggerRaw}; temporaryOptFlds=reason+dataset/{ExpectedCanonicalOptionalFieldsRaw}; GI=false"); - var dataSetName = "AR_G25A_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); plan = ArMms.MmsReportSubscriptionPlanner.BuildDynamicPlan( discovery.ReportInventory, discovery.IedDirectory, exactPoints.Select(point => point.UserReference), preferredLogicalDevice: selectedRcb.Domain, preferredRcbReference: selectedRcb.Reference, - dataSetName: dataSetName, + dataSetName: "AR_G25A_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(), strictRcb: true, allowUrCbFallback: true, allowPollingFallback: false); if (!DynamicReportActivationCommissioningService.ValidatePlanAgainstEnvelope(plan, selectedRcb.Reference, qualifiedReferences, out var planReason)) - { - evidence.Add("G2.5-A plan rejected: " + planReason); - return Failed("The G2.5-A strict plan did not preserve the exact G2.4-proven one-URCB/member identity.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences, plan.DataSetReference); - } + throw Abort("Strict G2.5-A plan rejected: " + planReason); evidence.Add($"G2.5-A plan: rcb={plan.ReportControl!.Reference}; dataset={plan.DataSetReference}; members={plan.DynamicPoints.Count}; mode={plan.Mode}; GI=false"); - var finalAvailability = await auxiliary.CheckReportControlAvailabilityAsync( + var postLeaseAvailability = await auxiliary.CheckReportControlAvailabilityAsync( oneRcb, discovery.IedDirectory, DynamicReportActivationCommissioningServiceV2.BuildPostLeaseAvailabilityOptions(selectedRcb.Reference), cancellationToken).ConfigureAwait(false); - var postLeaseSnapshot = finalAvailability.ReportControls.SingleOrDefault(); + var postLeaseSnapshot = postLeaseAvailability.ReportControls.SingleOrDefault(); evidence.Add($"G2.5-A post-lease ownership: availability={postLeaseSnapshot?.Availability}; Resv={TextOrDash(postLeaseSnapshot?.ReservationState)}; Owner={TextOrDash(postLeaseSnapshot?.Owner)}; localTcpAddress={TextOrDash(auxiliary.LocalTcpAddress)}; TrgOps={TextOrDash(postLeaseSnapshot?.TriggerOptions)}; OptFlds={TextOrDash(postLeaseSnapshot?.OptionalFields)}"); if (!IsPostLeaseUrcbSafeForDchg(postLeaseSnapshot, auxiliary.LocalTcpAddress, out var postLeaseReason)) - { - evidence.Add("G2.5-A post-lease URCB rejected: " + postLeaseReason); - return Failed("The exact URCB did not retain strict dchg-only caller-owned state after the proof-field lease.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences, plan.DataSetReference); - } + throw Abort("Post-lease dchg-only URCB gate failed: " + postLeaseReason); ApplyFreshSnapshot(plan.ReportControl!, postLeaseSnapshot!); EnsureAttribute(plan.ReportControl!, "TrgOps"); @@ -267,7 +246,8 @@ profile.AcceptedEnvelope is null || selectedRcb.OptionalFields = TemporaryOptionalFields; reportId = postLeaseSnapshot!.ReportId; - activationAttempted = true; + dynamicAttempted = true; + monitorCleanup = false; var attempt = await auxiliary.StartPersistentReportMonitorWithAttemptEvidenceAsync( plan, triggerGeneralInterrogation: false, @@ -284,8 +264,7 @@ profile.AcceptedEnvelope is null || foreach (var warning in attempt.CleanupWarnings) evidence.Add("G2.5-A failed-start cleanup warning: " + warning); monitorCleanup = attempt.CleanupSucceeded; - evidence.Add($"G2.5-A activation failed: cleanup={attempt.CleanupSucceeded}; reason={attempt.FailureReason}; result={attempt.StartResult.Message}"); - return Failed("G2.5-A could not arm the dchg-only monitor. Existing failed-start cleanup evidence is retained; the profile is unchanged.", evidence, identity, profile, loaded.FilePath, rcbReference, qualifiedReferences, plan.DataSetReference, monitorCleanup); + throw Abort($"Monitor activation failed: {attempt.FailureReason}; {attempt.StartResult.Message}"); } monitorSession = attempt.StartResult.Session; @@ -303,46 +282,51 @@ afterEnable is not null && afterEnable.IsSuccess && ParseBool(afterEnable.EnabledState) == true; activationProven = exactReadback && bindingAccepted && rptEnaAccepted && auxiliary.IsMmsInitiated; evidence.Add($"G2.5-A activation proof: success={activationProven}; datasetReadback={exactReadback}; binding={bindingAccepted}; RptEna={rptEnaAccepted}; associationHealthy={auxiliary.IsMmsInitiated}; GIrequested=false"); + if (!activationProven) + throw Abort("Activation evidence is incomplete; spontaneous receive will not be treated as proof."); + + progress?.Report($"G2.5-A ARMED — NO GI. Within {SpontaneousProofWindow.TotalSeconds:0}s, cause ONE normal physical/status change affecting one of the 8 proven points. Do not edit any RCB/DataSet manually."); + evidence.Add($"G2.5-A ARMED: report routing is active; GI=false. Waiting up to {SpontaneousProofWindow.TotalSeconds:0}s for a real spontaneous data-change report."); + + var receive = await auxiliary.ReceivePersistentReportMonitorSliceAsync( + monitorSession, + SpontaneousProofWindow, + pollDirectory: null, + pollReferences: null, + pollInterval: null, + triggerGeneralInterrogation: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + AppendWriteSteps(evidence, "G2.5-A receive", receive.WriteSteps); + evidence.Add($"G2.5-A receive: reports={receive.Reports.Count}; unrouted={auxiliary.UnroutedPersistentReportCount}; route={TextOrDash(auxiliary.LastReceiveRoutingSummary)}; GIrequested=false; result={receive.Message}"); - if (activationProven) + foreach (var frame in receive.Reports) { - progress?.Report($"G2.5-A ARMED — NO GI. Within {SpontaneousProofWindow.TotalSeconds:0}s, cause ONE normal physical/status change affecting one of the 8 proven points. Do not edit any RCB/DataSet manually."); - evidence.Add($"G2.5-A ARMED: monitor is enabled and routed; GI=false. Waiting up to {SpontaneousProofWindow.TotalSeconds:0}s for a spontaneous data-change report caused by a normal field/process change."); - - var receive = await auxiliary.ReceivePersistentReportMonitorSliceAsync( - monitorSession, - SpontaneousProofWindow, - pollDirectory: null, - pollReferences: null, - pollInterval: null, - triggerGeneralInterrogation: false, - cancellationToken: cancellationToken).ConfigureAwait(false); - AppendWriteSteps(evidence, "G2.5-A receive", receive.WriteSteps); - evidence.Add($"G2.5-A receive: reports={receive.Reports.Count}; unrouted={auxiliary.UnroutedPersistentReportCount}; route={TextOrDash(auxiliary.LastReceiveRoutingSummary)}; GIrequested=false; result={receive.Message}"); - - 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}"); - if (!validation.IsSuccess) - continue; - - spontaneousProven = true; - associationHealthyAfterReport = auxiliary.IsMmsInitiated; - 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"); - break; - } - - if (!spontaneousProven) - evidence.Add("G2.5-A spontaneous dchg proof: success=false; no received frame proved exact RptID + DataSet + valid included member mapping with data-change reason only. RptEna acceptance or unrelated reports are not success."); + 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}"); + if (!validation.IsSuccess) + continue; + + spontaneousProven = true; + associationHealthyAfterReport = auxiliary.IsMmsInitiated; + 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"); + break; } + + if (!spontaneousProven) + failureSummary = "No received frame proved exact spontaneous dchg semantics within the bounded window."; + } + catch (G25AbortException ex) + { + failureSummary = ex.Message; + evidence.Add("G2.5-A aborted fail-closed: " + ex.Message); } catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) { - evidence.Add($"G2.5-A exception: {ex.GetType().Name}: {ex.Message}"); + failureSummary = $"{ex.GetType().Name}: {ex.Message}"; + evidence.Add("G2.5-A exception: " + failureSummary); } finally { @@ -361,13 +345,10 @@ afterEnable is not null && afterEnable.IsSuccess && evidence.Add($"G2.5-A monitor cleanup exception: {ex.GetType().Name}: {ex.Message}"); } } - else if (!activationAttempted) - { - monitorCleanup = true; - } if (fieldLease is not null) { + fieldRestore = false; try { var restore = await auxiliary.RestoreDynamicRcbCommissioningFieldsAsync(fieldLease, CancellationToken.None).ConfigureAwait(false); @@ -379,20 +360,14 @@ afterEnable is not null && afterEnable.IsSuccess && } catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException) { - fieldRestore = false; evidence.Add($"G2.5-A proof-field restore exception: {ex.GetType().Name}: {ex.Message}"); } } - else - { - fieldRestore = true; - } await auxiliary.DisposeAsync().ConfigureAwait(false); } - var freshClosure = false; - if (plan is not null && activationAttempted) + if (plan is not null && fieldLease is not null) { freshClosure = await ProveFreshCleanupClosureAsync( device, @@ -401,18 +376,13 @@ afterEnable is not null && afterEnable.IsSuccess && evidence, CancellationToken.None).ConfigureAwait(false); } - else - { - freshClosure = monitorCleanup && fieldRestore; - } - var success = activationProven && - spontaneousProven && - associationHealthyAfterReport && - monitorCleanup && - fieldRestore && - freshClosure; + if (dynamicAttempted && monitorSession is null && !monitorCleanup) + evidence.Add("G2.5-A cleanup note: failed-start cleanup did not prove complete monitor rollback; fresh closure result is authoritative for final release state."); + + var success = activationProven && spontaneousProven && associationHealthyAfterReport && monitorCleanup && fieldRestore && freshClosure; evidence.Add($"G2.5-A combined result: activation={activationProven}; spontaneousDchg={spontaneousProven}; reportAssociationHealthy={associationHealthyAfterReport}; monitorCleanup={monitorCleanup}; proofFieldRestore={fieldRestore}; freshCleanupClosure={freshClosure}; success={success}"); + if (!string.IsNullOrWhiteSpace(failureSummary)) evidence.Add("G2.5-A failure reason: " + failureSummary); evidence.Add("G2.5-A safety: persisted profile remains InformationReportProven. Production automatic dynamic reporting remains OFF; this gate cannot set ProductionEligible."); return new DynamicReportSpontaneousDataChangeCommissioningResult @@ -426,7 +396,7 @@ afterEnable is not null && afterEnable.IsSuccess && AssociationHealthyAfterReport = associationHealthyAfterReport, 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 shown below; the persisted InformationReportProven profile is unchanged and 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.", Identity = identity, InputProfile = profile, RcbReference = rcbReference, @@ -437,52 +407,19 @@ afterEnable is not null && afterEnable.IsSuccess && IncludedMemberReferences = includedMembers, Reasons = includedReasons, ProfilePath = loaded.FilePath, - EvidenceLines = evidence + EvidenceLines = evidence.ToArray() }; } - internal static bool IsPostLeaseUrcbSafeForDchg( - ArMms.MmsRcbAvailabilitySnapshot? snapshot, - string localTcpAddress, - out string reason) + internal static bool IsPostLeaseUrcbSafeForDchg(ArMms.MmsRcbAvailabilitySnapshot? snapshot, string localTcpAddress, out string reason) { - if (snapshot is null) - { - reason = "Selected URCB is missing from post-lease readback."; - return false; - } - if (snapshot.Buffered) - { - reason = "G2.5-A permits URCB only."; - return false; - } - if (snapshot.DataSetProbeState != ArMms.MmsRcbDataSetProbeState.ReadSucceeded || !string.IsNullOrWhiteSpace(snapshot.DataSetReference)) - { - reason = "Post-lease DatSet must still be positively read and empty."; - return false; - } - if (ParseBool(snapshot.EnabledState) != false) - { - reason = $"Post-lease RptEna is not explicit false: {TextOrDash(snapshot.EnabledState)}"; - return false; - } - if (snapshot.Availability != ArMms.MmsRcbOperationalAvailability.UsedByCaller) - { - reason = $"Post-lease ownership is not UsedByCaller: {snapshot.Availability}."; - return false; - } - if (ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0) - { - reason = $"Post-lease reservation time is positive: {snapshot.ReservationTimeSeconds}."; - return false; - } - - if (HasOwner(snapshot.Owner) && - !ArMms.MmsRcbOwnerIdentity.MatchesLocalTcpAddress(snapshot.Owner, localTcpAddress, out var ownerReason)) - { - reason = "Post-lease Owner does not match the active G2.5-A MMS association: " + ownerReason; - return false; - } + if (snapshot is null) { reason = "Selected URCB is missing from post-lease readback."; return false; } + if (snapshot.Buffered) { reason = "G2.5-A permits URCB only."; return false; } + if (snapshot.DataSetProbeState != ArMms.MmsRcbDataSetProbeState.ReadSucceeded || !string.IsNullOrWhiteSpace(snapshot.DataSetReference)) { reason = "Post-lease DatSet must still be positively read and empty."; return false; } + if (ParseBool(snapshot.EnabledState) != false) { reason = $"Post-lease RptEna is not explicit false: {TextOrDash(snapshot.EnabledState)}"; return false; } + if (snapshot.Availability != ArMms.MmsRcbOperationalAvailability.UsedByCaller) { reason = $"Post-lease ownership is not UsedByCaller: {snapshot.Availability}."; return false; } + if (ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0) { reason = $"Post-lease reservation time is positive: {snapshot.ReservationTimeSeconds}."; return false; } + if (HasOwner(snapshot.Owner) && !ArMms.MmsRcbOwnerIdentity.MatchesLocalTcpAddress(snapshot.Owner, localTcpAddress, out var ownerReason)) { reason = "Post-lease Owner does not match the active G2.5-A MMS association: " + ownerReason; return false; } var triggers = ArMms.MmsReportControlFieldCodec.DecodeTriggerOptions(snapshot.TriggerOptions); if (!triggers.DataChange || triggers.GeneralInterrogation || triggers.Integrity || triggers.QualityChange || triggers.DataUpdate) @@ -492,37 +429,21 @@ internal static bool IsPostLeaseUrcbSafeForDchg( } var fields = ArMms.MmsReportControlFieldCodec.DecodeOptionalFields(snapshot.OptionalFields); - if (!fields.ReasonForInclusion || !fields.DataSetName || string.IsNullOrWhiteSpace(snapshot.ReportId)) - { - reason = $"Strict report identity fields missing: RptID={TextOrDash(snapshot.ReportId)}, reason={fields.ReasonForInclusion}, dataSetName={fields.DataSetName}."; - return false; - } - + if (!fields.ReasonForInclusion || !fields.DataSetName || string.IsNullOrWhiteSpace(snapshot.ReportId)) { reason = $"Strict report identity fields missing: RptID={TextOrDash(snapshot.ReportId)}, reason={fields.ReasonForInclusion}, dataSetName={fields.DataSetName}."; return false; } reason = "Caller-owned post-lease URCB is strict dchg-only with GI/integrity/qchg/dupd disabled and reason-for-inclusion + DataSet-name enabled."; return true; } - internal static DynamicReportSpontaneousDataChangeValidation ValidateSpontaneousDataChangeFrame( - ArMms.MmsReportFrame frame, - string expectedReportId, - string expectedDataSetReference, - IReadOnlyList qualifiedReferences) + internal static DynamicReportSpontaneousDataChangeValidation ValidateSpontaneousDataChangeFrame(ArMms.MmsReportFrame frame, string expectedReportId, string expectedDataSetReference, IReadOnlyList qualifiedReferences) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(qualifiedReferences); - - if (frame.DecoderMode.Equals("rejected-unmapped", StringComparison.OrdinalIgnoreCase)) - return Invalid("Report decoder quarantined the frame as unmapped."); - if (string.IsNullOrWhiteSpace(expectedReportId) || !frame.Header.ReportId.Trim().Equals(expectedReportId.Trim(), StringComparison.OrdinalIgnoreCase)) - return Invalid($"RptID mismatch. expected={TextOrDash(expectedReportId)}, actual={TextOrDash(frame.Header.ReportId)}"); - if (string.IsNullOrWhiteSpace(frame.Header.DataSetReference) || !SameReference(frame.Header.DataSetReference, expectedDataSetReference)) - return Invalid($"DataSet mismatch. expected={expectedDataSetReference}, actual={TextOrDash(frame.Header.DataSetReference)}"); - if (qualifiedReferences.Count == 0 || frame.Values.Count == 0) - return Invalid("Spontaneous dchg proof requires at least one included successful DataSet member."); - if (frame.IncludedDataSetIndexes.Count != frame.Values.Count) - return Invalid($"Included-index/value count mismatch: indexes={frame.IncludedDataSetIndexes.Count}, values={frame.Values.Count}."); - if (frame.IncludedDataSetIndexes.Distinct().Count() != frame.IncludedDataSetIndexes.Count) - return Invalid("Included DataSet indexes contain duplicates."); + if (frame.DecoderMode.Equals("rejected-unmapped", StringComparison.OrdinalIgnoreCase)) return Invalid("Report decoder quarantined the frame as unmapped."); + if (!string.Equals(frame.Header.ReportId?.Trim(), expectedReportId?.Trim(), StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(expectedReportId)) return Invalid($"RptID mismatch. expected={TextOrDash(expectedReportId)}, actual={TextOrDash(frame.Header.ReportId)}"); + if (string.IsNullOrWhiteSpace(frame.Header.DataSetReference) || !SameReference(frame.Header.DataSetReference, expectedDataSetReference)) return Invalid($"DataSet mismatch. expected={expectedDataSetReference}, actual={TextOrDash(frame.Header.DataSetReference)}"); + if (qualifiedReferences.Count == 0 || frame.Values.Count == 0) return Invalid("Spontaneous dchg proof requires at least one included successful DataSet member."); + if (frame.IncludedDataSetIndexes.Count != frame.Values.Count) return Invalid($"Included-index/value count mismatch: indexes={frame.IncludedDataSetIndexes.Count}, values={frame.Values.Count}."); + if (frame.IncludedDataSetIndexes.Distinct().Count() != frame.IncludedDataSetIndexes.Count) return Invalid("Included DataSet indexes contain duplicates."); var included = new List(); var members = new List(); @@ -531,36 +452,15 @@ internal static DynamicReportSpontaneousDataChangeValidation ValidateSpontaneous { var value = frame.Values[offset]; var dataSetIndex = frame.IncludedDataSetIndexes[offset]; - if (dataSetIndex < 0 || dataSetIndex >= qualifiedReferences.Count) - return Invalid($"Included DataSet index {dataSetIndex} is outside 0..{qualifiedReferences.Count - 1}."); - if (value.Index != dataSetIndex) - return Invalid($"Mapped value index mismatch at offset {offset}: included={dataSetIndex}, value.Index={value.Index}."); - if (value.Member is null || !SameReference(value.Member.MmsReference, qualifiedReferences[dataSetIndex])) - return Invalid($"Mapped member mismatch at DataSet index {dataSetIndex}: expected={qualifiedReferences[dataSetIndex]}, actual={value.Member?.MmsReference ?? ""}."); - if (value.Value is null || value.FailureCode.HasValue) - return Invalid($"Included member {qualifiedReferences[dataSetIndex]} has no successful process value (failure={value.FailureCode?.ToString() ?? "none"})."); - if (!string.IsNullOrWhiteSpace(value.DataReference) && - !SameReference(value.DataReference, qualifiedReferences[dataSetIndex]) && - !SameReference(value.DataReference, value.Member.UserReference)) - { - return Invalid($"DataRef mismatch at DataSet index {dataSetIndex}: actual={value.DataReference}."); - } + if (dataSetIndex < 0 || dataSetIndex >= qualifiedReferences.Count) return Invalid($"Included DataSet index {dataSetIndex} is outside 0..{qualifiedReferences.Count - 1}."); + if (value.Index != dataSetIndex) return Invalid($"Mapped value index mismatch at offset {offset}: included={dataSetIndex}, value.Index={value.Index}."); + if (value.Member is null || !SameReference(value.Member.MmsReference, qualifiedReferences[dataSetIndex])) return Invalid($"Mapped member mismatch at DataSet index {dataSetIndex}: expected={qualifiedReferences[dataSetIndex]}, actual={value.Member?.MmsReference ?? ""}."); + if (value.Value is null || value.FailureCode.HasValue) return Invalid($"Included member {qualifiedReferences[dataSetIndex]} has no successful process value (failure={value.FailureCode?.ToString() ?? "none"})."); + if (!string.IsNullOrWhiteSpace(value.DataReference) && !SameReference(value.DataReference, qualifiedReferences[dataSetIndex]) && !SameReference(value.DataReference, value.Member.UserReference)) return Invalid($"DataRef mismatch at DataSet index {dataSetIndex}: actual={value.DataReference}."); - var valueReasons = value.ReasonForInclusion - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Select(item => item.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (!valueReasons.Contains("data-change", StringComparer.OrdinalIgnoreCase)) - return Invalid($"Included member {qualifiedReferences[dataSetIndex]} does not carry reason-for-inclusion=data-change; reasons={TextOrDash(string.Join(",", valueReasons))}."); - if (valueReasons.Any(item => - item.Equals("general-interrogation", StringComparison.OrdinalIgnoreCase) || - item.Equals("integrity", StringComparison.OrdinalIgnoreCase) || - item.Equals("quality-change", StringComparison.OrdinalIgnoreCase) || - item.Equals("data-update", StringComparison.OrdinalIgnoreCase))) - { - return Invalid($"Included member {qualifiedReferences[dataSetIndex]} carries a non-dchg reason under a dchg-only lease: {string.Join(",", valueReasons)}."); - } + var valueReasons = value.ReasonForInclusion.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => item.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (!valueReasons.Contains("data-change", StringComparer.OrdinalIgnoreCase)) return Invalid($"Included member {qualifiedReferences[dataSetIndex]} does not carry reason-for-inclusion=data-change; reasons={TextOrDash(string.Join(",", valueReasons))}."); + if (valueReasons.Any(item => item.Equals("general-interrogation", StringComparison.OrdinalIgnoreCase) || item.Equals("integrity", StringComparison.OrdinalIgnoreCase) || item.Equals("quality-change", StringComparison.OrdinalIgnoreCase) || item.Equals("data-update", StringComparison.OrdinalIgnoreCase))) return Invalid($"Included member {qualifiedReferences[dataSetIndex]} carries a non-dchg reason under a dchg-only lease: {string.Join(",", valueReasons)}."); included.Add(dataSetIndex); members.Add(qualifiedReferences[dataSetIndex]); @@ -577,59 +477,27 @@ internal static DynamicReportSpontaneousDataChangeValidation ValidateSpontaneous }; } - private async Task ProveFreshCleanupClosureAsync( - Iec61850MonitorDevice device, - string rcbReference, - string temporaryDataSetReference, - ICollection evidence, - CancellationToken cancellationToken) + private async Task ProveFreshCleanupClosureAsync(Iec61850MonitorDevice device, string rcbReference, string temporaryDataSetReference, ICollection evidence, CancellationToken cancellationToken) { await using var fresh = new ArMms.MmsClientSession(); try { await fresh.ConnectAsync(device.IpAddress, device.Port, AuxiliaryAssociationTimeout, cancellationToken).ConfigureAwait(false); evidence.Add($"G2.5-A fresh cleanup association ready: state={fresh.State}; localTcpAddress={TextOrDash(fresh.LocalTcpAddress)}"); - var discovery = await fresh.DiscoverAsync( - probeReportAttributes: true, - maxReportAttributeProbes: 64, - cancellationToken: cancellationToken, - readDataSetDirectories: false, - maxDataSetDirectoryReads: 0).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("G2.5-A fresh cleanup: exact URCB not found."); - return false; - } - + if (rcb is null) { evidence.Add("G2.5-A fresh cleanup: exact URCB not found."); return false; } var oneRcb = new ArMms.MmsReportInventory(); oneRcb.ReportControls.Add(rcb); - var availability = await fresh.CheckReportControlAvailabilityAsync( - oneRcb, - discovery.IedDirectory, - new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, - cancellationToken).ConfigureAwait(false); + var availability = await fresh.CheckReportControlAvailabilityAsync(oneRcb, discovery.IedDirectory, new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, cancellationToken).ConfigureAwait(false); var snapshot = availability.ReportControls.SingleOrDefault(); - if (snapshot is not null) - evidence.Add($"G2.5-A fresh cleanup URCB: availability={snapshot.Availability}; probe={snapshot.DataSetProbeState}; DatSet={TextOrDash(snapshot.DataSetReference)}; RptEna={TextOrDash(snapshot.EnabledState)}; Resv={TextOrDash(snapshot.ReservationState)}; Owner={TextOrDash(snapshot.Owner)}; TrgOps={TextOrDash(snapshot.TriggerOptions)}; OptFlds={TextOrDash(snapshot.OptionalFields)}"); - - var nameAbsent = DynamicReportCleanupClosureCommissioningService.IsTemporaryDataSetAbsentFromNameList( - discovery.Snapshot, - temporaryDataSetReference, - out var nameReason); + if (snapshot is not null) evidence.Add($"G2.5-A fresh cleanup URCB: availability={snapshot.Availability}; probe={snapshot.DataSetProbeState}; DatSet={TextOrDash(snapshot.DataSetReference)}; RptEna={TextOrDash(snapshot.EnabledState)}; Resv={TextOrDash(snapshot.ReservationState)}; Owner={TextOrDash(snapshot.Owner)}; TrgOps={TextOrDash(snapshot.TriggerOptions)}; OptFlds={TextOrDash(snapshot.OptionalFields)}"); + var nameAbsent = DynamicReportCleanupClosureCommissioningService.IsTemporaryDataSetAbsentFromNameList(discovery.Snapshot, temporaryDataSetReference, out var nameReason); evidence.Add("G2.5-A fresh cleanup namespace: " + nameReason); - var directory = await fresh.GetDataSetDirectoryAsync(temporaryDataSetReference, discovery.IedDirectory, cancellationToken).ConfigureAwait(false); var directoryAbsent = !directory.IsSuccess; evidence.Add($"G2.5-A fresh cleanup DataSet directory: absent={directoryAbsent}; success={directory.IsSuccess}; members={directory.Members.Count}; result={directory.Message}"); - - var closed = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed( - snapshot, - nameAbsent, - directoryAbsent, - fresh.IsMmsInitiated, - out var closureReason); + var closed = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(snapshot, nameAbsent, directoryAbsent, fresh.IsMmsInitiated, out var closureReason); evidence.Add("G2.5-A fresh cleanup evaluation: " + closureReason); return closed; } @@ -640,119 +508,33 @@ private async Task ProveFreshCleanupClosureAsync( } } - 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 G25AbortException Abort(string message) => new(message); + private sealed class G25AbortException : Exception { public G25AbortException(string message) : base(message) { } } - 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 DynamicReportSpontaneousDataChangeValidation Invalid(string reason) - => new() { IsSuccess = false, Reason = reason }; - - private static bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual) - { - var left = expected.ToArray(); - var right = actual.ToArray(); - return left.Length == right.Length && left.Select(NormalizeReference).SequenceEqual(right.Select(NormalizeReference), 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? value) + private static void ApplyFreshSnapshot(ArMms.MmsReportControlCandidate target, ArMms.MmsRcbAvailabilitySnapshot source) { - var text = (value ?? string.Empty).Trim(); - if (text.Length == 0 || text == "-") return null; - if (bool.TryParse(text, out var parsed)) return parsed; - if (text is "1" or "01" || text.Equals("yes", StringComparison.OrdinalIgnoreCase) || text.Equals("on", StringComparison.OrdinalIgnoreCase)) return true; - if (text is "0" or "00" || text.Equals("no", StringComparison.OrdinalIgnoreCase) || text.Equals("off", StringComparison.OrdinalIgnoreCase)) return false; - return null; + 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 ulong? ParseUnsigned(string? value) - => ulong.TryParse((value ?? string.Empty).Trim(), out var parsed) ? parsed : null; + 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 DynamicReportSpontaneousDataChangeValidation Invalid(string reason) => new() { IsSuccess = false, Reason = reason }; + private static bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual) { var left = expected.ToArray(); var right = actual.ToArray(); return left.Length == right.Length && left.Select(NormalizeReference).SequenceEqual(right.Select(NormalizeReference), 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? value) { var text = (value ?? string.Empty).Trim(); if (text.Length == 0 || text == "-") return null; if (bool.TryParse(text, out var parsed)) return parsed; if (text is "1" or "01" || text.Equals("yes", StringComparison.OrdinalIgnoreCase) || text.Equals("on", StringComparison.OrdinalIgnoreCase)) return true; if (text is "0" or "00" || text.Equals("no", StringComparison.OrdinalIgnoreCase) || text.Equals("off", StringComparison.OrdinalIgnoreCase)) return false; return null; } + private static ulong? ParseUnsigned(string? value) => ulong.TryParse((value ?? string.Empty).Trim(), out var parsed) ? parsed : null; + private static bool HasOwner(string? value) { var text = (value ?? string.Empty).Trim(); if (text.Length == 0 || text == "-" || text == "[]" || text.Equals("null", StringComparison.OrdinalIgnoreCase)) return false; var compact = text.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(":", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal).Replace(" ", string.Empty, StringComparison.Ordinal); return compact.Length > 0 && compact.Any(character => character != '0'); } - private static bool HasOwner(string? value) - { - var text = (value ?? string.Empty).Trim(); - if (text.Length == 0 || text == "-" || text == "[]" || text.Equals("null", StringComparison.OrdinalIgnoreCase)) return false; - var compact = text.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase) - .Replace(":", string.Empty, StringComparison.Ordinal) - .Replace("-", string.Empty, StringComparison.Ordinal) - .Replace(" ", string.Empty, StringComparison.Ordinal); - return compact.Length > 0 && compact.Any(character => character != '0'); - } + private static DynamicReportSpontaneousDataChangeCommissioningResult Blocked(string summary, IReadOnlyList evidence, ArMms.MmsDynamicReportIedIdentity? identity = null, string profilePath = "", ArMms.MmsDynamicReportQualificationProfile? profile = null) + => new() { IsBlocked = true, Summary = summary, Identity = identity, InputProfile = profile, ProfilePath = profilePath, EvidenceLines = evidence.ToArray() }; - private static DynamicReportSpontaneousDataChangeCommissioningResult Blocked( - string summary, - IReadOnlyList evidence, - ArMms.MmsDynamicReportIedIdentity? identity = null, - string profilePath = "", - ArMms.MmsDynamicReportQualificationProfile? profile = null) - => new() - { - IsBlocked = true, - Summary = summary, - Identity = identity, - InputProfile = profile, - ProfilePath = profilePath, - EvidenceLines = evidence.ToArray() - }; - - private static DynamicReportSpontaneousDataChangeCommissioningResult Failed( - string summary, - IReadOnlyList evidence, - ArMms.MmsDynamicReportIedIdentity identity, - ArMms.MmsDynamicReportQualificationProfile profile, - string profilePath, - string rcbReference, - IReadOnlyList memberReferences, - string dataSetReference = "", - bool monitorCleanupSucceeded = false) - => new() - { - IsSuccess = false, - Summary = summary, - Identity = identity, - InputProfile = profile, - RcbReference = rcbReference, - DataSetReference = dataSetReference, - MemberReferences = memberReferences.ToArray(), - MonitorCleanupSucceeded = monitorCleanupSucceeded, - ProfilePath = profilePath, - EvidenceLines = evidence.ToArray() - }; + private static DynamicReportSpontaneousDataChangeCommissioningResult FailedBeforeMutation(string summary, IReadOnlyList evidence, ArMms.MmsDynamicReportIedIdentity identity, ArMms.MmsDynamicReportQualificationProfile profile, string profilePath, string rcbReference, IReadOnlyList memberReferences) + => 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(); + private static string TextOrDash(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); } From 75d98eb19abcbad2b8bfbfa9909b94e33accaf92 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 10:56:46 +0700 Subject: [PATCH 06/12] G2.5-A1: add independent read-only stimulus witness --- ...portStimulusWitnessCommissioningService.cs | 431 ++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 Services/DynamicReportStimulusWitnessCommissioningService.cs diff --git a/Services/DynamicReportStimulusWitnessCommissioningService.cs b/Services/DynamicReportStimulusWitnessCommissioningService.cs new file mode 100644 index 00000000..dc47c421 --- /dev/null +++ b/Services/DynamicReportStimulusWitnessCommissioningService.cs @@ -0,0 +1,431 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportStimulusWitnessTransition +{ + public int Index { get; init; } + public string MemberReference { 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 DynamicReportStimulusWitnessResult +{ + public bool ArmedObserved { get; init; } + public bool BaselineCaptured { get; init; } + public bool ChangeObserved { get; init; } + public bool AssociationHealthy { get; init; } + public int SampleCycles { get; init; } + public int ReadFailures { get; init; } + public IReadOnlyList BaselineValues { get; init; } = Array.Empty(); + public IReadOnlyList Transitions { get; init; } = Array.Empty(); + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); + public string Summary { get; init; } = string.Empty; +} + +internal sealed class DynamicReportStimulusWitnessCommissioningResult +{ + public bool IsSuccess { get; init; } + public bool StimulusWitnessProven { get; init; } + public bool ReportCorrelationProven { get; init; } + public IReadOnlyList CorrelatedIndexes { get; init; } = Array.Empty(); + public string Summary { get; init; } = string.Empty; + public DynamicReportSpontaneousDataChangeCommissioningResult CoreResult { get; init; } = new(); + public DynamicReportStimulusWitnessResult Witness { get; init; } = new(); + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// G2.5-A1 diagnostic wrapper around the physical G2.5-A dchg proof. +/// +/// The existing reporting association remains unchanged and still performs the exact +/// one-URCB dchg-only / NO-GI proof. A second MMS association is read-only and samples +/// only the exact G2.4-proven members. It never reads or writes RCB attributes, never +/// defines/deletes a DataSet, and never sends GI. Its sole purpose is to prove whether +/// the operator stimulus actually changed one of the qualified members while G2.5-A +/// was armed. +/// +internal sealed class DynamicReportStimulusWitnessCommissioningService +{ + private static readonly TimeSpan AssociationTimeout = TimeSpan.FromSeconds(10); + internal static readonly TimeSpan WitnessWindow = TimeSpan.FromSeconds(55); + internal static readonly TimeSpan WitnessInterCycleDelay = TimeSpan.FromMilliseconds(50); + internal const string ArmedMarker = "G2.5-A ARMED — NO GI"; + internal const string WitnessReadyMarker = "G2.5-A1 WITNESS READY"; + + private readonly DynamicReportQualificationProfileStore _profileStore; + + public DynamicReportStimulusWitnessCommissioningService( + 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 evidence = new List + { + "G2.5-A1 contract: reporting association is unchanged G2.5-A dchg-only/NO-GI; witness association is READ ONLY and samples only the exact proven members.", + "G2.5-A1 operator contract: do not stimulate on the first ARMED message; wait until G2.5-A1 WITNESS READY is shown." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return FailedBeforeCore("G2.5-A1 identity preflight failed: " + ex.Message, evidence); + } + + var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (!loaded.IsValid || loaded.Profile is null || + loaded.Profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven || + loaded.Profile.RcbActivationProof?.IsSuccess != true) + { + evidence.Add($"G2.5-A1 profile gate: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + return FailedBeforeCore("G2.5-A1 requires the identity-compatible InformationReportProven G2.4 profile.", evidence); + } + + var memberReferences = loaded.Profile.RcbActivationProof.MemberReferences.ToArray(); + if (memberReferences.Length == 0) + return FailedBeforeCore("G2.5-A1 profile has no exact proven member sequence.", evidence); + + evidence.Add($"G2.5-A1 target: members={memberReferences.Length}; stableKey={identity.StableIdentityKey}; profileState={loaded.Profile.State}"); + evidence.Add("G2.5-A1 exact members: " + string.Join(" | ", memberReferences)); + + var armed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var witnessCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var relay = new RelayProgress(text => + { + progress?.Report(text); + if (text.Contains(ArmedMarker, StringComparison.OrdinalIgnoreCase)) + armed.TrySetResult(true); + }); + + var witnessTask = RunWitnessAsync( + device, + memberReferences, + armed.Task, + progress, + witnessCancellation.Token); + + var coreService = new DynamicReportSpontaneousDataChangeCommissioningService(_profileStore); + DynamicReportSpontaneousDataChangeCommissioningResult coreResult; + try + { + coreResult = await coreService.RunAsync( + device, + fullModelSignals, + relay, + cancellationToken).ConfigureAwait(false); + } + finally + { + if (!armed.Task.IsCompleted) + witnessCancellation.Cancel(); + } + + DynamicReportStimulusWitnessResult witnessResult; + try + { + witnessResult = await witnessTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + witnessResult = new DynamicReportStimulusWitnessResult + { + Summary = "G2.5-A1 witness was cancelled because the core G2.5-A attempt never reached ARMED.", + EvidenceLines = ["G2.5-A1 witness: core attempt did not reach ARMED; no stimulus conclusion is possible."] + }; + } + + evidence.AddRange(coreResult.EvidenceLines.Select(line => "CORE/" + line)); + evidence.AddRange(witnessResult.EvidenceLines); + + var changedIndexes = witnessResult.Transitions.Select(item => item.Index).Distinct().OrderBy(index => index).ToArray(); + var correlatedIndexes = coreResult.IncludedIndexes.Intersect(changedIndexes).Distinct().OrderBy(index => index).ToArray(); + var correlationProven = coreResult.SpontaneousDataChangeProven && correlatedIndexes.Length > 0; + var witnessProven = witnessResult.BaselineCaptured && witnessResult.ChangeObserved && witnessResult.AssociationHealthy; + var success = coreResult.IsSuccess && correlationProven; + + string diagnosis; + if (success) + { + diagnosis = $"G2.5-A/A1 PASS: read-only witness observed a real qualified-member transition and the spontaneous data-change InformationReport included the same DataSet index(es) [{string.Join(",", correlatedIndexes)}]."; + } + else if (!coreResult.ActivationProven) + { + diagnosis = "G2.5-A1 is inconclusive because the core dchg-only report activation did not reach a proven ARMED state."; + } + else if (!witnessResult.BaselineCaptured || !witnessResult.AssociationHealthy) + { + diagnosis = "G2.5-A1 witness is inconclusive because the independent read-only association could not maintain a reliable baseline/sample window."; + } + else if (!witnessResult.ChangeObserved && !coreResult.SpontaneousDataChangeProven) + { + diagnosis = "G2.5-A1 diagnosis: no qualified-member transition was observed during the armed window and no spontaneous report arrived. The physical stimulus is not yet proven to touch the 8-member envelope."; + } + else if (witnessResult.ChangeObserved && !coreResult.SpontaneousDataChangeProven) + { + diagnosis = $"G2.5-A1 diagnosis: stimulus WAS witnessed on qualified DataSet index(es) [{string.Join(",", changedIndexes)}], but no valid spontaneous dchg InformationReport arrived. This isolates the next investigation to IED dchg/report emission or receive-path evidence, not stimulus ambiguity."; + } + else if (coreResult.SpontaneousDataChangeProven && !correlationProven) + { + diagnosis = "G2.5-A core report proof passed, but the independent witness did not observe a transition on any index included by that report; G2.5-A1 correlation remains unproven."; + } + else + { + diagnosis = "G2.5-A1 did not close the stimulus/report correlation gate."; + } + + evidence.Add($"G2.5-A1 combined: coreSuccess={coreResult.IsSuccess}; activation={coreResult.ActivationProven}; coreDchg={coreResult.SpontaneousDataChangeProven}; witnessBaseline={witnessResult.BaselineCaptured}; witnessChange={witnessResult.ChangeObserved}; witnessHealthy={witnessResult.AssociationHealthy}; witnessChanged=[{string.Join(",", changedIndexes)}]; reportIncluded=[{string.Join(",", coreResult.IncludedIndexes)}]; correlated=[{string.Join(",", correlatedIndexes)}]; success={success}"); + evidence.Add("G2.5-A1 diagnosis: " + diagnosis); + evidence.Add("G2.5-A1 safety: witness performs no RCB/DataSet writes and does not alter the persisted InformationReportProven profile or production policy."); + + return new DynamicReportStimulusWitnessCommissioningResult + { + IsSuccess = success, + StimulusWitnessProven = witnessProven, + ReportCorrelationProven = correlationProven, + CorrelatedIndexes = correlatedIndexes, + Summary = diagnosis + " Production automatic dynamic reporting remains OFF.", + CoreResult = coreResult, + Witness = witnessResult, + EvidenceLines = evidence.ToArray() + }; + } + + private async Task RunWitnessAsync( + Iec61850MonitorDevice device, + IReadOnlyList memberReferences, + Task armedSignal, + IProgress? progress, + CancellationToken cancellationToken) + { + var evidence = new List(); + await using var witness = new ArMms.MmsClientSession(); + try + { + await witness.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.5-A1 witness association ready: state={witness.State}; localTcpAddress={TextOrDash(witness.LocalTcpAddress)}; READ-ONLY=true"); + + var discovery = await witness.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + discovery.IedDirectory, + memberReferences, + out var exactPoints, + out var exactReason)) + { + evidence.Add("G2.5-A1 witness member resolution failed: " + exactReason); + return WitnessFailure("Witness could not resolve the exact qualified member sequence.", evidence, witness.IsMmsInitiated); + } + + evidence.Add("G2.5-A1 witness prepared exact read-only member set and is waiting for core ARMED state."); + await armedSignal.WaitAsync(cancellationToken).ConfigureAwait(false); + + var baseline = await ReadWitnessValuesAsync(witness, exactPoints, cancellationToken).ConfigureAwait(false); + if (!baseline.IsSuccess) + { + evidence.Add("G2.5-A1 witness baseline failed: " + baseline.Message); + return WitnessFailure("Witness baseline could not be captured completely.", evidence, witness.IsMmsInitiated, baseline.ReadFailures); + } + + evidence.Add("G2.5-A1 witness baseline: " + string.Join(" | ", memberReferences.Select((reference, index) => $"[{index}] {reference}={baseline.Values[index]}"))); + progress?.Report($"{WitnessReadyMarker} — NOW perform ONE safe physical/process stimulus that changes one of the 8 proven points. Witness is read-only; NO GI."); + evidence.Add($"{WitnessReadyMarker}: baseline complete; sampling starts now for up to {WitnessWindow.TotalSeconds:0}s."); + + var deadline = DateTimeOffset.UtcNow + WitnessWindow; + var cycles = 0; + var readFailures = 0; + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + var current = await ReadWitnessValuesAsync(witness, exactPoints, cancellationToken).ConfigureAwait(false); + cycles++; + readFailures += current.ReadFailures; + + if (!witness.IsMmsInitiated) + return WitnessFailure("Witness association left MmsInitiated during the stimulus window.", evidence, false, readFailures, baseline.Values, cycles); + + if (current.IsSuccess) + { + var transitions = CompareStimulusWitnessSamples(memberReferences, baseline.Values, current.Values, DateTimeOffset.UtcNow); + if (transitions.Count > 0) + { + foreach (var transition in transitions) + evidence.Add($"G2.5-A1 WITNESSED TRANSITION: index={transition.Index}; member={transition.MemberReference}; before={transition.BeforeValue}; after={transition.AfterValue}; at={transition.ObservedAtUtc:O}"); + + return new DynamicReportStimulusWitnessResult + { + ArmedObserved = true, + BaselineCaptured = true, + ChangeObserved = true, + AssociationHealthy = witness.IsMmsInitiated, + SampleCycles = cycles, + ReadFailures = readFailures, + BaselineValues = baseline.Values, + Transitions = transitions, + EvidenceLines = evidence.ToArray(), + Summary = $"Witness observed {transitions.Count} qualified-member transition(s) after ARMED." + }; + } + } + + if (WitnessInterCycleDelay > TimeSpan.Zero) + await Task.Delay(WitnessInterCycleDelay, cancellationToken).ConfigureAwait(false); + } + + evidence.Add($"G2.5-A1 witness window ended: transitions=0; cycles={cycles}; readFailures={readFailures}; associationHealthy={witness.IsMmsInitiated}"); + return new DynamicReportStimulusWitnessResult + { + ArmedObserved = true, + BaselineCaptured = true, + ChangeObserved = false, + AssociationHealthy = witness.IsMmsInitiated, + SampleCycles = cycles, + ReadFailures = readFailures, + BaselineValues = baseline.Values, + EvidenceLines = evidence.ToArray(), + Summary = "No qualified-member transition was observed by the independent read-only witness during the armed window." + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"G2.5-A1 witness exception: {ex.GetType().Name}: {ex.Message}"); + return WitnessFailure("Witness failed before a conclusive stimulus observation.", evidence, witness.IsMmsInitiated); + } + } + + private static async Task ReadWitnessValuesAsync( + 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) + { + failures++; + values[index] = ""; + continue; + } + + values[index] = ExtractWitnessValue(read.Message); + } + + return new WitnessReadBatch + { + IsSuccess = failures == 0, + ReadFailures = failures, + Values = values, + Message = failures == 0 ? "all reads succeeded" : $"{failures} of {points.Count} reads failed" + }; + } + + internal static IReadOnlyList CompareStimulusWitnessSamples( + IReadOnlyList memberReferences, + IReadOnlyList baselineValues, + IReadOnlyList currentValues, + DateTimeOffset observedAtUtc) + { + ArgumentNullException.ThrowIfNull(memberReferences); + ArgumentNullException.ThrowIfNull(baselineValues); + ArgumentNullException.ThrowIfNull(currentValues); + if (memberReferences.Count != baselineValues.Count || memberReferences.Count != currentValues.Count) + throw new ArgumentException("Stimulus witness arrays must have identical lengths."); + + var transitions = new List(); + for (var index = 0; index < memberReferences.Count; index++) + { + if (string.Equals(baselineValues[index], currentValues[index], StringComparison.OrdinalIgnoreCase)) + continue; + if (baselineValues[index] == "" || currentValues[index] == "") + continue; + + transitions.Add(new DynamicReportStimulusWitnessTransition + { + Index = index, + MemberReference = memberReferences[index], + BeforeValue = baselineValues[index], + AfterValue = currentValues[index], + ObservedAtUtc = observedAtUtc + }); + } + return transitions; + } + + internal static string ExtractWitnessValue(string? readMessage) + { + var text = (readMessage ?? string.Empty).Trim(); + const string marker = "decoded value:"; + var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (markerIndex >= 0) + text = text[(markerIndex + marker.Length)..].Trim(); + return text.TrimEnd('.').Trim(); + } + + private static DynamicReportStimulusWitnessResult WitnessFailure( + string summary, + IReadOnlyList evidence, + bool associationHealthy, + int readFailures = 0, + IReadOnlyList? baseline = null, + int cycles = 0) + => new() + { + BaselineCaptured = baseline is { Count: > 0 }, + AssociationHealthy = associationHealthy, + SampleCycles = cycles, + ReadFailures = readFailures, + BaselineValues = baseline?.ToArray() ?? Array.Empty(), + Summary = summary, + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportStimulusWitnessCommissioningResult FailedBeforeCore(string summary, IReadOnlyList evidence) + => new() + { + Summary = summary + " Production automatic dynamic reporting remains OFF.", + EvidenceLines = evidence.ToArray() + }; + + 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 WitnessReadBatch + { + 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 20c6d75c5bad75864922751ede66b16f543d293b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 10:57:02 +0700 Subject: [PATCH 07/12] G2.5-A1: add stimulus witness evidence window --- ...icReportQualificationResultWindow.G25A1.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 DynamicReportQualificationResultWindow.G25A1.cs diff --git a/DynamicReportQualificationResultWindow.G25A1.cs b/DynamicReportQualificationResultWindow.G25A1.cs new file mode 100644 index 00000000..f78cc16c --- /dev/null +++ b/DynamicReportQualificationResultWindow.G25A1.cs @@ -0,0 +1,82 @@ +using System.Text; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +internal partial class DynamicReportQualificationResultWindow +{ + internal DynamicReportQualificationResultWindow(DynamicReportStimulusWitnessCommissioningResult result) + { + ArgumentNullException.ThrowIfNull(result); + InitializeComponent(); + + Title = "G2.5-A1 Stimulus Witness Evidence"; + HeaderText.Text = "G2.5-A1 Stimulus Witness + dchg Correlation"; + SummaryText.Text = result.Summary; + StateText.Text = result.IsSuccess + ? "Stimulus/report correlation Proven" + : result.StimulusWitnessProven + ? "Stimulus proven; report correlation not proven" + : "Stimulus witness not proven"; + EvidenceTextBox.Text = BuildG25A1Evidence(result); + + if (result.IsSuccess) + SetPassBadge(); + } + + private static string BuildG25A1Evidence(DynamicReportStimulusWitnessCommissioningResult result) + { + var core = result.CoreResult; + var witness = result.Witness; + var builder = new StringBuilder(); + builder.AppendLine("ARSAS G2.5-A1 STIMULUS WITNESS + SPONTANEOUS DCHG CORRELATION EVIDENCE"); + builder.AppendLine(new string('=', 88)); + builder.AppendLine($"Result: {result.Summary}"); + builder.AppendLine($"G2.5-A1 success: {result.IsSuccess}"); + builder.AppendLine($"Stimulus witness proven: {result.StimulusWitnessProven}"); + builder.AppendLine($"Report correlation proven: {result.ReportCorrelationProven}"); + builder.AppendLine($"Correlated indexes: [{string.Join(",", result.CorrelatedIndexes)}]"); + + builder.AppendLine(); + builder.AppendLine("CORE G2.5-A REPORT PATH"); + builder.AppendLine($"Activation proven: {core.ActivationProven}"); + builder.AppendLine($"Spontaneous data-change proven: {core.SpontaneousDataChangeProven}"); + builder.AppendLine($"Report association healthy: {core.AssociationHealthyAfterReport}"); + builder.AppendLine($"Report included indexes: [{string.Join(",", core.IncludedIndexes)}]"); + builder.AppendLine($"Report reasons: [{string.Join(",", core.Reasons)}]"); + builder.AppendLine($"Monitor cleanup: {core.MonitorCleanupSucceeded}"); + builder.AppendLine($"Proof-field restore: {core.ProofFieldRestoreSucceeded}"); + builder.AppendLine($"Fresh cleanup closure: {core.FreshCleanupClosureSucceeded}"); + + builder.AppendLine(); + builder.AppendLine("INDEPENDENT READ-ONLY STIMULUS WITNESS"); + builder.AppendLine($"ARMED observed: {witness.ArmedObserved}"); + builder.AppendLine($"Baseline captured: {witness.BaselineCaptured}"); + builder.AppendLine($"Qualified-member change observed: {witness.ChangeObserved}"); + builder.AppendLine($"Witness association healthy: {witness.AssociationHealthy}"); + builder.AppendLine($"Sample cycles: {witness.SampleCycles}"); + builder.AppendLine($"Read failures: {witness.ReadFailures}"); + if (witness.BaselineValues.Count > 0) + { + builder.AppendLine("Baseline values:"); + for (var index = 0; index < witness.BaselineValues.Count && index < core.MemberReferences.Count; index++) + builder.AppendLine($" [{index}] {core.MemberReferences[index]} = {witness.BaselineValues[index]}"); + } + builder.AppendLine("Witnessed transitions:"); + foreach (var transition in witness.Transitions) + builder.AppendLine($" [{transition.Index}] {transition.MemberReference}: {transition.BeforeValue} -> {transition.AfterValue} @ {transition.ObservedAtUtc:O}"); + + builder.AppendLine(); + builder.AppendLine("WIRE / DIAGNOSTIC EVIDENCE"); + foreach (var line in result.EvidenceLines) + builder.AppendLine(line); + + builder.AppendLine(); + builder.AppendLine("SAFETY STATE"); + builder.AppendLine("The G2.5-A1 witness association is read-only and does not read/write RCB attributes or mutate DataSets."); + builder.AppendLine("The core G2.5-A path still sends NO GI and remains one-URCB commissioning only."); + builder.AppendLine("The persisted InformationReportProven profile is not advanced by this gate."); + builder.AppendLine("Production automatic dynamic reporting remains OFF."); + return builder.ToString(); + } +} From 62faed5a0c3fd12218c2b5540062604dd5d64b1e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 10:57:40 +0700 Subject: [PATCH 08/12] G2.5-A1: integrate witness into Ctrl+Shift+D field flow --- DynamicReportQualificationUiBehavior.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/DynamicReportQualificationUiBehavior.cs b/DynamicReportQualificationUiBehavior.cs index 977d8be5..9a158011 100644 --- a/DynamicReportQualificationUiBehavior.cs +++ b/DynamicReportQualificationUiBehavior.cs @@ -217,23 +217,24 @@ private static async Task RunG25ASpontaneousDataChangeAsync(MainWindow window, M { var answer = MessageBox.Show( window, - $"Run G2.5-A spontaneous dchg proof for {device.Name} ({device.EndpointText})?\n\n" + - "ACTIVE COMMISSIONING — ONE URCB / NO GI\n\n" + - "ARSAS will use the stored InformationReportProven profile and EXACT G2.4-proven URCB + 8-member set. It will capture original report-control fields, temporarily set TrgOps to dchg ONLY (canonical 0240), request reason-for-inclusion + data-set-name (061800), create one temporary DataSet, enable the URCB, and arm report routing.\n\n" + - "ARSAS WILL NOT SEND GI. After the status bar says 'G2.5-A ARMED — NO GI', cause ONE normal physical/process status change that affects one of the 8 proven points. You may use an already-tested normal OPEN/CLOSE operation if it naturally changes one of those status points, or another safe field stimulus. Do NOT manually edit any RCB or DataSet.\n\n" + - "PASS requires an ACTUAL spontaneous InformationReport with exact RptID/DataSet, valid included member mapping, and reason-for-inclusion=data-change. GI/integrity/quality-change/data-update reports are explicitly rejected as G2.5-A proof.\n\n" + - "After the bounded wait, ARSAS disables/cleans the monitor, restores exact proof fields, closes the association, then opens a NEW read-only association and requires DatSet empty, RptEna=false, Resv=false, Owner empty and temporary DataSet absent. The persisted InformationReportProven profile is NOT modified and production dynamic reporting remains OFF.\n\n" + + $"Run G2.5-A1 spontaneous dchg + independent stimulus witness for {device.Name} ({device.EndpointText})?\n\n" + + "ACTIVE COMMISSIONING — ONE URCB / NO GI + READ-ONLY WITNESS\n\n" + + "The core path is unchanged G2.5-A: exact G2.4-proven URCB + 8-member set, temporary TrgOps=dchg ONLY (0240), reason-for-inclusion + data-set-name (061800), one temporary DataSet, RptEna=true, NO GI, and strict spontaneous data-change report validation.\n\n" + + "G2.5-A1 adds a SECOND MMS association that is READ ONLY. It resolves and samples only the same 8 proven process/status members. It does NOT read/write RCB attributes, does NOT Define/Delete a DataSet, and does NOT send GI.\n\n" + + "IMPORTANT OPERATOR SEQUENCE: when the status first says 'G2.5-A ARMED — NO GI', DO NOT stimulate yet. Wait for the second status 'G2.5-A1 WITNESS READY'. Only then cause ONE normal safe physical/process status change affecting one of the 8 proven points. Do NOT manually edit any RCB or DataSet.\n\n" + + "The witness records baseline -> changed values and DataSet indexes. If no report arrives, the evidence will now distinguish 'stimulus did not touch the envelope' from 'qualified member changed but no dchg report arrived'. If a report arrives, G2.5-A1 correlates the witnessed changed index with the report included index.\n\n" + + "The existing G2.5-A cleanup/restore/fresh-association closure remains mandatory. The persisted InformationReportProven profile is NOT modified and production dynamic reporting remains OFF.\n\n" + "Continue?", - "G2.5-A Spontaneous dchg Proof", + "G2.5-A1 Stimulus Witness", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); if (answer != MessageBoxResult.Yes) return; - window.LastStatusText = $"G2.5-A: preparing exact G2.4-proven URCB on an isolated auxiliary association to {device.Name}…"; + window.LastStatusText = $"G2.5-A1: preparing dchg-only report path plus independent read-only witness for {device.Name}…"; var progress = new Progress(text => window.LastStatusText = text); - var service = new DynamicReportSpontaneousDataChangeCommissioningService(); + var service = new DynamicReportStimulusWitnessCommissioningService(); var result = await service.RunAsync( device, device.Signals.ToArray(), From 84d5b815a2f48ef0b12853d81fd3c6a03c95db8c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 10:57:58 +0700 Subject: [PATCH 09/12] G2.5-A1: regress read-only witness and stimulus correlation --- ...timulusWitnessCommissioningServiceTests.cs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/ARSAS.Tests/DynamicReportStimulusWitnessCommissioningServiceTests.cs diff --git a/tests/ARSAS.Tests/DynamicReportStimulusWitnessCommissioningServiceTests.cs b/tests/ARSAS.Tests/DynamicReportStimulusWitnessCommissioningServiceTests.cs new file mode 100644 index 00000000..af6a48eb --- /dev/null +++ b/tests/ARSAS.Tests/DynamicReportStimulusWitnessCommissioningServiceTests.cs @@ -0,0 +1,114 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class DynamicReportStimulusWitnessCommissioningServiceTests +{ + [Theory] + [InlineData("Native MMS Confirmed-Read decoded value: false.", "false")] + [InlineData("Native MMS Confirmed-Read decoded value: true.", "true")] + [InlineData("Native MMS Confirmed-Read decoded value: 12.5.", "12.5")] + [InlineData("custom-value", "custom-value")] + public void WitnessValueExtractor_NormalizesReadEvidence(string message, string expected) + { + Assert.Equal(expected, DynamicReportStimulusWitnessCommissioningService.ExtractWitnessValue(message)); + } + + [Fact] + public void WitnessComparison_ReportsOnlyQualifiedIndexesThatActuallyChanged() + { + var refs = new[] { "LD0/GGIO1$ST$A$stVal", "LD0/GGIO1$ST$B$stVal", "LD0/GGIO1$ST$C$stVal" }; + var observed = DateTimeOffset.Parse("2026-08-21T10:00:00Z"); + + var transitions = DynamicReportStimulusWitnessCommissioningService.CompareStimulusWitnessSamples( + refs, + ["false", "false", "true"], + ["false", "true", "false"], + observed); + + Assert.Equal(2, transitions.Count); + Assert.Equal(1, transitions[0].Index); + Assert.Equal(refs[1], transitions[0].MemberReference); + Assert.Equal("false", transitions[0].BeforeValue); + Assert.Equal("true", transitions[0].AfterValue); + Assert.Equal(2, transitions[1].Index); + Assert.Equal(observed, transitions[1].ObservedAtUtc); + } + + [Fact] + public void WitnessComparison_IgnoresReadFailureSentinels() + { + var transitions = DynamicReportStimulusWitnessCommissioningService.CompareStimulusWitnessSamples( + ["LD0/GGIO1$ST$A$stVal"], + ["false"], + [""], + DateTimeOffset.UtcNow); + + Assert.Empty(transitions); + } + + [Fact] + public void WitnessComparison_RejectsMismatchedArrayLengths() + { + Assert.Throws(() => + DynamicReportStimulusWitnessCommissioningService.CompareStimulusWitnessSamples( + ["A", "B"], + ["false"], + ["false", "true"], + DateTimeOffset.UtcNow)); + } + + [Fact] + public void G25A1_SourceKeepsWitnessReadOnlyAndCoreNoGi() + { + var witness = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "DynamicReportStimulusWitnessCommissioningService.cs")); + var core = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "DynamicReportSpontaneousDataChangeCommissioningService.cs")); + var ui = File.ReadAllText(Path.Combine(RepoRoot(), "DynamicReportQualificationUiBehavior.cs")); + var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); + + Assert.Contains("ReadSingleVariableAsync", witness, StringComparison.Ordinal); + Assert.Contains("probeReportAttributes: false", witness, StringComparison.Ordinal); + Assert.Contains("G2.5-A1 WITNESS READY", witness, StringComparison.Ordinal); + Assert.Contains("CompareStimulusWitnessSamples", witness, StringComparison.Ordinal); + Assert.Contains("Intersect(changedIndexes)", witness, StringComparison.Ordinal); + + Assert.DoesNotContain("WriteReportAttributeAsync", witness, StringComparison.Ordinal); + Assert.DoesNotContain("PrepareDynamicRcbCommissioningFieldsAsync", witness, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableListAsync", witness, StringComparison.Ordinal); + Assert.DoesNotContain("DeleteNamedVariableListAsync", witness, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitor", witness, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", witness, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", witness, StringComparison.Ordinal); + + Assert.Contains("triggerGeneralInterrogation: false", core, StringComparison.Ordinal); + Assert.DoesNotContain("triggerGeneralInterrogation: true", core, StringComparison.Ordinal); + Assert.Contains("G2.5-A1 spontaneous dchg + independent stimulus witness", ui, StringComparison.Ordinal); + Assert.Contains("DO NOT stimulate yet", ui, StringComparison.Ordinal); + Assert.Contains("G2.5-A1 WITNESS READY", ui, StringComparison.Ordinal); + Assert.Contains("DynamicReportStimulusWitnessCommissioningService", ui, StringComparison.Ordinal); + + Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal); + } + + [Fact] + public void G25A1_UsesBoundedWindowShorterThanCoreProofWindow() + { + Assert.True(DynamicReportStimulusWitnessCommissioningService.WitnessWindow > TimeSpan.Zero); + Assert.True(DynamicReportStimulusWitnessCommissioningService.WitnessWindow < DynamicReportSpontaneousDataChangeCommissioningService.SpontaneousProofWindow); + Assert.True(DynamicReportStimulusWitnessCommissioningService.WitnessInterCycleDelay > TimeSpan.Zero); + } + + private static string RepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current != null) + { + if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj"))) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("ARSAS repository root not found."); + } +} From a336612d10f0182e4f4f871d2236f233f08103e5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 11:03:19 +0700 Subject: [PATCH 10/12] G2.5-A1: keep core G2.5-A physical baseline regression explicit --- ...eousDataChangeCommissioningServiceTests.cs | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/tests/ARSAS.Tests/DynamicReportSpontaneousDataChangeCommissioningServiceTests.cs b/tests/ARSAS.Tests/DynamicReportSpontaneousDataChangeCommissioningServiceTests.cs index 84178918..855243f6 100644 --- a/tests/ARSAS.Tests/DynamicReportSpontaneousDataChangeCommissioningServiceTests.cs +++ b/tests/ARSAS.Tests/DynamicReportSpontaneousDataChangeCommissioningServiceTests.cs @@ -37,11 +37,11 @@ public void PostLeaseGate_AcceptsStrictDchgOnlyCallerOwnedState() } [Theory] - [InlineData("0244")] // dchg + GI - [InlineData("0248")] // dchg + integrity - [InlineData("0260")] // dchg + qchg - [InlineData("0250")] // dchg + dupd - [InlineData("0204")] // GI only + [InlineData("0244")] + [InlineData("0248")] + [InlineData("0260")] + [InlineData("0250")] + [InlineData("0204")] public void PostLeaseGate_RejectsAnyNonDchgOnlyTriggerShape(string trgOps) { var ok = DynamicReportSpontaneousDataChangeCommissioningService.IsPostLeaseUrcbSafeForDchg( @@ -78,12 +78,7 @@ public void SpontaneousGate_AcceptsPartialIncludedMemberWithDataChangeReason() { var refs = Refs(); var members = Members(); - var frame = Frame( - "R1", - "LD0/LLN0.AR_G25A_TEST", - [members[1]], - [1], - ["data-change"]); + var frame = Frame("R1", "LD0/LLN0.AR_G25A_TEST", [members[1]], [1], ["data-change"]); var result = DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame( frame, @@ -102,12 +97,7 @@ public void SpontaneousGate_AcceptsMultipleCorrectDataChangeMembersWithoutRequir { var refs = Refs(); var members = Members(); - var frame = Frame( - "R1", - "LD0/LLN0.AR_G25A_TEST", - [members[0], members[2]], - [0, 2], - ["data-change"]); + var frame = Frame("R1", "LD0/LLN0.AR_G25A_TEST", [members[0], members[2]], [0, 2], ["data-change"]); var result = DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame( frame, @@ -203,6 +193,7 @@ public void G25A_SourceRequiresFreshCleanupClosureAndDoesNotTouchProductionPolic Assert.Contains("e.Key != Key.D", ui, StringComparison.Ordinal); Assert.Contains("RunG25ASpontaneousDataChangeAsync", ui, StringComparison.Ordinal); Assert.Contains("G2.5-A ARMED — NO GI", ui, StringComparison.Ordinal); + Assert.Contains("G2.5-A1 WITNESS READY", ui, StringComparison.Ordinal); Assert.Contains("Do NOT manually edit any RCB or DataSet", ui, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicUrcb = true", runtime, StringComparison.Ordinal); From 4e69d82ddd1f3e375620d88add91005ea6b989a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 11:08:08 +0700 Subject: [PATCH 11/12] G2.5-A1: clarify witness-ready operator sequence --- DynamicReportQualificationUiBehavior.cs | 71 +++++-------------------- 1 file changed, 14 insertions(+), 57 deletions(-) diff --git a/DynamicReportQualificationUiBehavior.cs b/DynamicReportQualificationUiBehavior.cs index 9a158011..a42ba6b3 100644 --- a/DynamicReportQualificationUiBehavior.cs +++ b/DynamicReportQualificationUiBehavior.cs @@ -104,16 +104,9 @@ private static async Task RunG23Async(MainWindow window, Models.Iec61850MonitorD window.LastStatusText = $"G2.3 qualification: opening isolated auxiliary MMS association to {device.Name}…"; var service = new DynamicReportQualificationCommissioningService(); - var result = await service.RunAsync( - device, - device.Signals.ToArray(), - CancellationToken.None); - + var result = await service.RunAsync(device, device.Signals.ToArray(), CancellationToken.None); window.LastStatusText = result.Summary; - var evidenceWindow = new DynamicReportQualificationResultWindow(result) - { - Owner = window - }; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } @@ -136,16 +129,9 @@ private static async Task RunP0TriggerProbeAsync(MainWindow window, Models.Iec61 window.LastStatusText = $"P0: opening isolated auxiliary MMS association to {device.Name} for one-URCB TrgOps micro-probe…"; var service = new DynamicReportTriggerOptionsProbeCommissioningService(); - var result = await service.RunAsync( - device, - device.Signals.ToArray(), - CancellationToken.None); - + var result = await service.RunAsync(device, device.Signals.ToArray(), CancellationToken.None); window.LastStatusText = result.Summary; - var evidenceWindow = new DynamicReportQualificationResultWindow(result) - { - Owner = window - }; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } @@ -168,16 +154,9 @@ private static async Task RunP1OptionalFieldsProbeAsync(MainWindow window, Model window.LastStatusText = $"P1: opening isolated auxiliary MMS association to {device.Name} for one-URCB OptFlds micro-probe…"; var service = new DynamicReportOptionalFieldsProbeCommissioningService(); - var result = await service.RunAsync( - device, - device.Signals.ToArray(), - CancellationToken.None); - + var result = await service.RunAsync(device, device.Signals.ToArray(), CancellationToken.None); window.LastStatusText = result.Summary; - var evidenceWindow = new DynamicReportQualificationResultWindow(result) - { - Owner = window - }; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } @@ -200,16 +179,9 @@ private static async Task RunG24CleanupClosureAsync(MainWindow window, Models.Ie window.LastStatusText = $"G2.4-C: opening fresh read-only auxiliary MMS association to {device.Name}…"; var service = new DynamicReportCleanupClosureCommissioningService(); - var result = await service.RunAsync( - device, - device.Signals.ToArray(), - CancellationToken.None); - + var result = await service.RunAsync(device, device.Signals.ToArray(), CancellationToken.None); window.LastStatusText = result.Summary; - var evidenceWindow = new DynamicReportQualificationResultWindow(result) - { - Owner = window - }; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } @@ -221,8 +193,8 @@ private static async Task RunG25ASpontaneousDataChangeAsync(MainWindow window, M "ACTIVE COMMISSIONING — ONE URCB / NO GI + READ-ONLY WITNESS\n\n" + "The core path is unchanged G2.5-A: exact G2.4-proven URCB + 8-member set, temporary TrgOps=dchg ONLY (0240), reason-for-inclusion + data-set-name (061800), one temporary DataSet, RptEna=true, NO GI, and strict spontaneous data-change report validation.\n\n" + "G2.5-A1 adds a SECOND MMS association that is READ ONLY. It resolves and samples only the same 8 proven process/status members. It does NOT read/write RCB attributes, does NOT Define/Delete a DataSet, and does NOT send GI.\n\n" + - "IMPORTANT OPERATOR SEQUENCE: when the status first says 'G2.5-A ARMED — NO GI', DO NOT stimulate yet. Wait for the second status 'G2.5-A1 WITNESS READY'. Only then cause ONE normal safe physical/process status change affecting one of the 8 proven points. Do NOT manually edit any RCB or DataSet.\n\n" + - "The witness records baseline -> changed values and DataSet indexes. If no report arrives, the evidence will now distinguish 'stimulus did not touch the envelope' from 'qualified member changed but no dchg report arrived'. If a report arrives, G2.5-A1 correlates the witnessed changed index with the report included index.\n\n" + + "IMPORTANT: the status may briefly show 'G2.5-A ARMED — NO GI'. DO NOT stimulate on that message. WAIT until the status changes to 'G2.5-A1 WITNESS READY'. Only then cause ONE normal safe physical/process status change affecting one of the 8 proven points. Do NOT manually edit any RCB or DataSet.\n\n" + + "The witness records baseline -> changed values and DataSet indexes. If no report arrives, evidence distinguishes 'stimulus did not touch the envelope' from 'qualified member changed but no dchg report arrived'. If a report arrives, G2.5-A1 correlates the witnessed changed index with the report included index.\n\n" + "The existing G2.5-A cleanup/restore/fresh-association closure remains mandatory. The persisted InformationReportProven profile is NOT modified and production dynamic reporting remains OFF.\n\n" + "Continue?", "G2.5-A1 Stimulus Witness", @@ -235,17 +207,9 @@ private static async Task RunG25ASpontaneousDataChangeAsync(MainWindow window, M window.LastStatusText = $"G2.5-A1: preparing dchg-only report path plus independent read-only witness for {device.Name}…"; var progress = new Progress(text => window.LastStatusText = text); var service = new DynamicReportStimulusWitnessCommissioningService(); - var result = await service.RunAsync( - device, - device.Signals.ToArray(), - progress, - CancellationToken.None); - + var result = await service.RunAsync(device, device.Signals.ToArray(), progress, CancellationToken.None); window.LastStatusText = result.Summary; - var evidenceWindow = new DynamicReportQualificationResultWindow(result) - { - Owner = window - }; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } @@ -269,16 +233,9 @@ private static async Task RunG24Async(MainWindow window, Models.Iec61850MonitorD window.LastStatusText = $"G2.4: opening isolated auxiliary MMS association to {device.Name} for transactional one-URCB proof…"; var service = new DynamicReportActivationCommissioningServiceV2(); - var result = await service.RunAsync( - device, - device.Signals.ToArray(), - CancellationToken.None); - + var result = await service.RunAsync(device, device.Signals.ToArray(), CancellationToken.None); window.LastStatusText = result.Summary; - var evidenceWindow = new DynamicReportQualificationResultWindow(result) - { - Owner = window - }; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; evidenceWindow.ShowDialog(); } } From 547fd5be3e73a07e3a4b4b84984a08373b08207b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 21 Aug 2026 11:13:20 +0700 Subject: [PATCH 12/12] G2.5-A1: align operator-sequence regression with clarified UX --- .../DynamicReportStimulusWitnessCommissioningServiceTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/DynamicReportStimulusWitnessCommissioningServiceTests.cs b/tests/ARSAS.Tests/DynamicReportStimulusWitnessCommissioningServiceTests.cs index af6a48eb..5109bef3 100644 --- a/tests/ARSAS.Tests/DynamicReportStimulusWitnessCommissioningServiceTests.cs +++ b/tests/ARSAS.Tests/DynamicReportStimulusWitnessCommissioningServiceTests.cs @@ -83,7 +83,8 @@ public void G25A1_SourceKeepsWitnessReadOnlyAndCoreNoGi() Assert.Contains("triggerGeneralInterrogation: false", core, StringComparison.Ordinal); Assert.DoesNotContain("triggerGeneralInterrogation: true", core, StringComparison.Ordinal); Assert.Contains("G2.5-A1 spontaneous dchg + independent stimulus witness", ui, StringComparison.Ordinal); - Assert.Contains("DO NOT stimulate yet", ui, StringComparison.Ordinal); + Assert.Contains("DO NOT stimulate on that message", ui, StringComparison.Ordinal); + Assert.Contains("WAIT until", ui, StringComparison.Ordinal); Assert.Contains("G2.5-A1 WITNESS READY", ui, StringComparison.Ordinal); Assert.Contains("DynamicReportStimulusWitnessCommissioningService", ui, StringComparison.Ordinal);