diff --git a/src/AR.Iec61850/Mms/MmsDynamicDataSetQualificationRecovery.cs b/src/AR.Iec61850/Mms/MmsDynamicDataSetQualificationRecovery.cs
index c229b86d..6ca9fdd4 100644
--- a/src/AR.Iec61850/Mms/MmsDynamicDataSetQualificationRecovery.cs
+++ b/src/AR.Iec61850/Mms/MmsDynamicDataSetQualificationRecovery.cs
@@ -3,9 +3,9 @@ namespace AR.Iec61850.Mms;
///
/// Typed evidence from a fresh-association recovery of one exact G2.3 temporary
/// NamedVariableList. Recovery is intentionally narrower than qualification itself:
-/// it may delete only when a fresh directory proves that the surviving list contains
-/// the exact ordered members from the failed current-run attempt. A name match alone
-/// never authorizes mutation.
+/// it may delete only when a fresh readable directory proves that the surviving list
+/// contains the exact ordered members from the failed current-run attempt. A name match
+/// alone never authorizes mutation, and an inspection exception never proves absence.
///
public sealed class MmsDynamicDataSetQualificationRecoveryResult
{
@@ -13,13 +13,17 @@ public sealed class MmsDynamicDataSetQualificationRecoveryResult
public string DataSetReference { get; init; } = string.Empty;
public IReadOnlyList ExpectedMemberReferences { get; init; } = Array.Empty();
public bool NamePresentBefore { get; init; }
+ public bool NamespaceAbsenceProvenBefore { get; init; }
public bool DirectoryReadableBefore { get; init; }
+ public bool DirectoryAbsenceProvenBefore { get; init; }
public IReadOnlyList ObservedMemberReferencesBefore { get; init; } = Array.Empty();
public bool ExactMembersVerifiedBeforeDelete { get; init; }
public bool DeleteAttempted { get; init; }
public MmsDeleteNamedVariableListResult? DeleteResult { get; init; }
public bool NamePresentAfter { get; init; }
+ public bool NamespaceAbsenceProvenAfter { get; init; }
public bool DirectoryReadableAfter { get; init; }
+ public bool DirectoryAbsenceProvenAfter { get; init; }
public bool AssociationHealthy { get; init; }
public string Failure { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty;
@@ -65,7 +69,7 @@ public static bool CanDeleteExactResidue(
if (!namePresent && !directoryReadable)
{
- reason = "No surviving qualification residue is visible; no delete is required.";
+ reason = "No readable surviving qualification residue is available for exact delete proof.";
return false;
}
@@ -93,7 +97,9 @@ public static bool CanDeleteExactResidue(
public static bool IsRecoveryClosed(
bool namePresent,
+ bool namespaceAbsenceProven,
bool directoryReadable,
+ bool directoryAbsenceProven,
bool associationHealthy,
out string reason)
{
@@ -109,13 +115,25 @@ public static bool IsRecoveryClosed(
return false;
}
+ if (!namespaceAbsenceProven)
+ {
+ reason = "Temporary qualification DataSet namespace absence was not proven; missing/failed discovery is not absence evidence.";
+ return false;
+ }
+
if (directoryReadable)
{
reason = "Temporary qualification DataSet still has a readable directory on the fresh association.";
return false;
}
- reason = "Fresh-association qualification cleanup is closed: temporary DataSet absent by namespace and direct directory, association healthy.";
+ if (!directoryAbsenceProven)
+ {
+ reason = "Temporary qualification DataSet direct-directory absence was not proven; an exception is not absence evidence.";
+ return false;
+ }
+
+ reason = "Fresh-association qualification cleanup is closed: temporary DataSet absent by fresh namespace and completed direct-directory checks, association healthy.";
return true;
}
@@ -127,11 +145,11 @@ public sealed partial class MmsClientSession
{
///
/// Recovers only the exact temporary NamedVariableList from a failed G2.3 attempt.
- /// The caller must invoke this on a newly established MMS association. If the list is
- /// already absent the operation is read-only. If it survives, DeleteNamedVariableList
- /// is allowed only after fresh directory readback exactly matches the failed attempt's
- /// ordered member sequence. Closure is then re-proven by fresh namespace + direct
- /// directory absence while the association remains healthy.
+ /// Invoke this on a newly established MMS association. If the list is already absent,
+ /// the operation is read-only. If it survives, DeleteNamedVariableList is allowed only
+ /// after fresh directory readback exactly matches the failed attempt's ordered member
+ /// sequence. Closure is then re-proven by successful namespace discovery, a completed
+ /// direct-directory absence check, and a healthy association.
///
public async Task RecoverDynamicDataSetQualificationResidueAsync(
string dataSetReference,
@@ -149,33 +167,25 @@ public async Task RecoverDynamicDa
throw new ArgumentException("Fresh qualification recovery member references cannot contain duplicates.", nameof(expectedMemberReferences));
var expected = expectedMemberReferences.Select(reference => reference.Trim()).ToArray();
- var evidence = new List();
- evidence.Add($"G2.3 fresh recovery target: dataset={dataSetReference}; expectedMembers={expected.Length}; mutationPolicy=exact-current-run-residue-only");
+ var evidence = new List
+ {
+ $"G2.3 fresh recovery target: dataset={dataSetReference}; expectedMembers={expected.Length}; mutationPolicy=exact-current-run-residue-only"
+ };
var before = await InspectQualificationResidueAsync(dataSetReference, cancellationToken).ConfigureAwait(false);
evidence.AddRange(before.EvidenceLines.Select(line => "G2.3 fresh recovery BEFORE: " + line));
var initiallyClosed = MmsDynamicDataSetQualificationRecoveryPolicy.IsRecoveryClosed(
before.NamePresent,
+ before.NamespaceAbsenceProven,
before.DirectoryReadable,
+ before.DirectoryAbsenceProven,
IsMmsInitiated,
out var initialClosureReason);
evidence.Add("G2.3 fresh recovery initial closure: " + initialClosureReason);
if (initiallyClosed)
- {
- return new MmsDynamicDataSetQualificationRecoveryResult
- {
- IsSuccess = true,
- DataSetReference = dataSetReference,
- ExpectedMemberReferences = expected,
- NamePresentBefore = before.NamePresent,
- DirectoryReadableBefore = before.DirectoryReadable,
- ObservedMemberReferencesBefore = before.MemberReferences,
- AssociationHealthy = IsMmsInitiated,
- Summary = "G2.3 fresh recovery PASS: the temporary qualification DataSet was already absent on the fresh association; no delete mutation was required.",
- EvidenceLines = evidence
- };
- }
+ return BuildResult(true, dataSetReference, expected, before, null, false, false, null, IsMmsInitiated,
+ "G2.3 fresh recovery PASS: the temporary qualification DataSet was already proven absent on the fresh association; no delete mutation was required.", evidence);
var mayDelete = MmsDynamicDataSetQualificationRecoveryPolicy.CanDeleteExactResidue(
before.NamePresent,
@@ -186,21 +196,8 @@ public async Task RecoverDynamicDa
out var deleteReason);
evidence.Add("G2.3 fresh recovery delete gate: " + deleteReason);
if (!mayDelete)
- {
- return new MmsDynamicDataSetQualificationRecoveryResult
- {
- DataSetReference = dataSetReference,
- ExpectedMemberReferences = expected,
- NamePresentBefore = before.NamePresent,
- DirectoryReadableBefore = before.DirectoryReadable,
- ObservedMemberReferencesBefore = before.MemberReferences,
- ExactMembersVerifiedBeforeDelete = false,
- AssociationHealthy = IsMmsInitiated,
- Failure = deleteReason,
- Summary = "G2.3 fresh recovery failed closed before delete. " + deleteReason,
- EvidenceLines = evidence
- };
- }
+ return BuildResult(false, dataSetReference, expected, before, null, false, false, deleteReason, IsMmsInitiated,
+ "G2.3 fresh recovery failed closed before delete. " + deleteReason, evidence);
var delete = await SendQualificationRecoveryDeleteAsync(dataSetReference, cancellationToken).ConfigureAwait(false);
evidence.Add($"G2.3 fresh recovery DELETE: attempted=true; success={delete.IsSuccess}; matched={delete.NumberMatched?.ToString() ?? "?"}; deleted={delete.NumberDeleted?.ToString() ?? "?"}; association={State}; result={delete.Message}");
@@ -209,52 +206,36 @@ public async Task RecoverDynamicDa
var failure = !delete.IsSuccess
? "Exact temporary qualification DataSet delete was not accepted."
: "MMS association was not healthy after exact temporary qualification DataSet delete.";
- return new MmsDynamicDataSetQualificationRecoveryResult
- {
- DataSetReference = dataSetReference,
- ExpectedMemberReferences = expected,
- NamePresentBefore = before.NamePresent,
- DirectoryReadableBefore = before.DirectoryReadable,
- ObservedMemberReferencesBefore = before.MemberReferences,
- ExactMembersVerifiedBeforeDelete = true,
- DeleteAttempted = true,
- DeleteResult = delete,
- AssociationHealthy = IsMmsInitiated,
- Failure = failure,
- Summary = "G2.3 fresh recovery failed closed after targeted delete. " + failure,
- EvidenceLines = evidence
- };
+ return BuildResult(false, dataSetReference, expected, before, null, true, true, failure, IsMmsInitiated,
+ "G2.3 fresh recovery failed closed after targeted delete. " + failure, evidence, delete);
}
var after = await InspectQualificationResidueAsync(dataSetReference, cancellationToken).ConfigureAwait(false);
evidence.AddRange(after.EvidenceLines.Select(line => "G2.3 fresh recovery AFTER: " + line));
var closed = MmsDynamicDataSetQualificationRecoveryPolicy.IsRecoveryClosed(
after.NamePresent,
+ after.NamespaceAbsenceProven,
after.DirectoryReadable,
+ after.DirectoryAbsenceProven,
IsMmsInitiated,
out var closureReason);
evidence.Add("G2.3 fresh recovery final closure: " + closureReason);
- return new MmsDynamicDataSetQualificationRecoveryResult
- {
- IsSuccess = closed,
- DataSetReference = dataSetReference,
- ExpectedMemberReferences = expected,
- NamePresentBefore = before.NamePresent,
- DirectoryReadableBefore = before.DirectoryReadable,
- ObservedMemberReferencesBefore = before.MemberReferences,
- ExactMembersVerifiedBeforeDelete = true,
- DeleteAttempted = true,
- DeleteResult = delete,
- NamePresentAfter = after.NamePresent,
- DirectoryReadableAfter = after.DirectoryReadable,
- AssociationHealthy = IsMmsInitiated,
- Failure = closed ? string.Empty : closureReason,
- Summary = closed
- ? "G2.3 fresh recovery PASS: exact current-run qualification residue was deleted and fresh namespace + direct directory absence were proven on a healthy association."
+ return BuildResult(
+ closed,
+ dataSetReference,
+ expected,
+ before,
+ after,
+ true,
+ true,
+ closed ? null : closureReason,
+ IsMmsInitiated,
+ closed
+ ? "G2.3 fresh recovery PASS: exact current-run qualification residue was deleted and fresh namespace + direct-directory absence were proven on a healthy association."
: "G2.3 fresh recovery did not prove complete cleanup closure after exact targeted delete. " + closureReason,
- EvidenceLines = evidence
- };
+ evidence,
+ delete);
}
private async Task InspectQualificationResidueAsync(
@@ -278,8 +259,8 @@ private async Task InspectQualificationResidueAs
return new QualificationResidueInspection { EvidenceLines = evidence };
}
- var namePresent = QualificationDataSetNamePresent(discovery.Snapshot, dataSetReference, out var namespaceReason);
- evidence.Add("namespace: " + namespaceReason);
+ var namespaceState = InspectQualificationDataSetNamespace(discovery.Snapshot, dataSetReference);
+ evidence.Add("namespace: " + namespaceState.Reason);
MmsDataSetDirectoryResult directory;
try
@@ -288,23 +269,27 @@ private async Task InspectQualificationResidueAs
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
{
- evidence.Add($"direct directory exception={ex.GetType().Name}: {ex.Message}; association={State}");
+ evidence.Add($"direct directory exception={ex.GetType().Name}: {ex.Message}; association={State}; absenceProven=false");
return new QualificationResidueInspection
{
- NamePresent = namePresent,
+ NamePresent = namespaceState.Present,
+ NamespaceAbsenceProven = namespaceState.AbsenceProven,
EvidenceLines = evidence
};
}
var members = directory.Members.Select(member => member.MmsReference).ToArray();
- evidence.Add($"direct directory: readable={directory.IsSuccess}; members={members.Length}; association={State}; result={directory.Message}");
+ var directoryAbsenceProven = !directory.IsSuccess;
+ evidence.Add($"direct directory: completed=true; readable={directory.IsSuccess}; absenceProven={directoryAbsenceProven}; members={members.Length}; association={State}; result={directory.Message}");
if (directory.IsSuccess)
evidence.Add("direct directory members: " + string.Join(" | ", members));
return new QualificationResidueInspection
{
- NamePresent = namePresent,
+ NamePresent = namespaceState.Present,
+ NamespaceAbsenceProven = namespaceState.AbsenceProven,
DirectoryReadable = directory.IsSuccess,
+ DirectoryAbsenceProven = directoryAbsenceProven,
MemberReferences = members,
EvidenceLines = evidence
};
@@ -333,33 +318,85 @@ private async Task SendQualificationRecoveryDe
}
}
- private static bool QualificationDataSetNamePresent(
+ private static QualificationNamespaceState InspectQualificationDataSetNamespace(
MmsDiscoverySnapshot snapshot,
- string dataSetReference,
- out string reason)
+ string dataSetReference)
{
ArgumentNullException.ThrowIfNull(snapshot);
var (domain, itemName) = MmsDataSetDirectoryRequest.ParseDataSetReference(dataSetReference);
var normalizedItem = itemName.Trim().Replace('.', '$');
if (!snapshot.DomainVariableLists.TryGetValue(domain, out var names))
{
- reason = $"domain {domain} is absent from NamedVariableList discovery; absence cannot be proven by namespace alone.";
- return false;
+ return new QualificationNamespaceState
+ {
+ Present = false,
+ AbsenceProven = false,
+ Reason = $"domain {domain} is absent from NamedVariableList discovery; namespace absence is not proven."
+ };
}
var present = names.Any(name =>
(name ?? string.Empty).Trim().Replace('.', '$').Equals(normalizedItem, StringComparison.OrdinalIgnoreCase));
- reason = present
- ? $"temporary qualification DataSet is advertised: domain={domain}; item={normalizedItem}."
- : $"temporary qualification DataSet is absent from NamedVariableList discovery: domain={domain}; item={normalizedItem}; advertisedLists={names.Count}.";
- return present;
+ return new QualificationNamespaceState
+ {
+ Present = present,
+ AbsenceProven = !present,
+ Reason = present
+ ? $"temporary qualification DataSet is advertised: domain={domain}; item={normalizedItem}."
+ : $"temporary qualification DataSet absence is proven by NamedVariableList discovery: domain={domain}; item={normalizedItem}; advertisedLists={names.Count}."
+ };
}
+ private static MmsDynamicDataSetQualificationRecoveryResult BuildResult(
+ bool success,
+ string dataSetReference,
+ IReadOnlyList expected,
+ QualificationResidueInspection before,
+ QualificationResidueInspection? after,
+ bool exactMembers,
+ bool deleteAttempted,
+ string? failure,
+ bool associationHealthy,
+ string summary,
+ IReadOnlyList evidence,
+ MmsDeleteNamedVariableListResult? delete = null)
+ => new()
+ {
+ IsSuccess = success,
+ DataSetReference = dataSetReference,
+ ExpectedMemberReferences = expected.ToArray(),
+ NamePresentBefore = before.NamePresent,
+ NamespaceAbsenceProvenBefore = before.NamespaceAbsenceProven,
+ DirectoryReadableBefore = before.DirectoryReadable,
+ DirectoryAbsenceProvenBefore = before.DirectoryAbsenceProven,
+ ObservedMemberReferencesBefore = before.MemberReferences.ToArray(),
+ ExactMembersVerifiedBeforeDelete = exactMembers,
+ DeleteAttempted = deleteAttempted,
+ DeleteResult = delete,
+ NamePresentAfter = after?.NamePresent ?? false,
+ NamespaceAbsenceProvenAfter = after?.NamespaceAbsenceProven ?? false,
+ DirectoryReadableAfter = after?.DirectoryReadable ?? false,
+ DirectoryAbsenceProvenAfter = after?.DirectoryAbsenceProven ?? false,
+ AssociationHealthy = associationHealthy,
+ Failure = failure ?? string.Empty,
+ Summary = summary,
+ EvidenceLines = evidence.ToArray()
+ };
+
private sealed class QualificationResidueInspection
{
public bool NamePresent { get; init; }
+ public bool NamespaceAbsenceProven { get; init; }
public bool DirectoryReadable { get; init; }
+ public bool DirectoryAbsenceProven { get; init; }
public IReadOnlyList MemberReferences { get; init; } = Array.Empty();
public IReadOnlyList EvidenceLines { get; init; } = Array.Empty();
}
+
+ private sealed class QualificationNamespaceState
+ {
+ public bool Present { get; init; }
+ public bool AbsenceProven { get; init; }
+ public string Reason { get; init; } = string.Empty;
+ }
}
diff --git a/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetQualificationRecoveryTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetQualificationRecoveryTests.cs
index e73653b4..7cae0516 100644
--- a/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetQualificationRecoveryTests.cs
+++ b/tests/AR.Iec61850.Tests/Mms/MmsDynamicDataSetQualificationRecoveryTests.cs
@@ -60,27 +60,53 @@ public void NameOnlyWithoutReadableDirectory_NeverAuthorizesDelete()
}
[Fact]
- public void ClosedRecovery_RequiresNamespaceAndDirectoryAbsenceAndHealthyAssociation()
+ public void ClosedRecovery_RequiresBothAbsenceProofsAndHealthyAssociation()
{
Assert.True(MmsDynamicDataSetQualificationRecoveryPolicy.IsRecoveryClosed(
namePresent: false,
+ namespaceAbsenceProven: true,
directoryReadable: false,
+ directoryAbsenceProven: true,
associationHealthy: true,
out var passReason), passReason);
+ Assert.False(MmsDynamicDataSetQualificationRecoveryPolicy.IsRecoveryClosed(
+ namePresent: false,
+ namespaceAbsenceProven: false,
+ directoryReadable: false,
+ directoryAbsenceProven: true,
+ associationHealthy: true,
+ out var namespaceReason));
+ Assert.Contains("namespace absence was not proven", namespaceReason, StringComparison.OrdinalIgnoreCase);
+
+ Assert.False(MmsDynamicDataSetQualificationRecoveryPolicy.IsRecoveryClosed(
+ namePresent: false,
+ namespaceAbsenceProven: true,
+ directoryReadable: false,
+ directoryAbsenceProven: false,
+ associationHealthy: true,
+ out var directoryReason));
+ Assert.Contains("direct-directory absence was not proven", directoryReason, StringComparison.OrdinalIgnoreCase);
+
Assert.False(MmsDynamicDataSetQualificationRecoveryPolicy.IsRecoveryClosed(
namePresent: true,
+ namespaceAbsenceProven: false,
directoryReadable: false,
+ directoryAbsenceProven: true,
associationHealthy: true,
out _));
Assert.False(MmsDynamicDataSetQualificationRecoveryPolicy.IsRecoveryClosed(
namePresent: false,
+ namespaceAbsenceProven: true,
directoryReadable: true,
+ directoryAbsenceProven: false,
associationHealthy: true,
out _));
Assert.False(MmsDynamicDataSetQualificationRecoveryPolicy.IsRecoveryClosed(
namePresent: false,
+ namespaceAbsenceProven: true,
directoryReadable: false,
+ directoryAbsenceProven: true,
associationHealthy: false,
out _));
}