diff --git a/IedCardActionsGearPolicy.cs b/IedCardActionsGearPolicy.cs
index 0fbed72d3..e15ea5ec9 100644
--- a/IedCardActionsGearPolicy.cs
+++ b/IedCardActionsGearPolicy.cs
@@ -11,13 +11,13 @@ namespace ArIED61850Tester;
///
/// Adds a dedicated Lucide-style gear button to every IED card. The existing pencil
/// remains the direct Edit Signals shortcut; the gear is the explicit entry point for
-/// the reusable IED Actions chooser (Static DataSet, Select Signals, RCB Engineering,
+/// the reusable IED Actions chooser (DataSet Signals, Signal Catalog, RCB Engineering,
/// COMTRADE and Browse Offline).
///
internal static class IedCardActionsGearPolicy
{
private const string GearUid = "ARSAS.IedActionsGear";
- private const string GearToolTip = "IED Actions — Static DataSet, Select Signals, RCB Engineering, COMTRADE, Browse Offline";
+ private const string GearToolTip = "IED Actions — DataSet Signals, Signal Catalog, RCB Engineering, COMTRADE, Browse Offline";
private const double GearOpticalSize = 16d;
// Exact Lucide Settings outline supplied by the product owner, translated from the
diff --git a/IedWorkspaceActionsCardBehavior.cs b/IedWorkspaceActionsCardBehavior.cs
index fb557b7f0..a93b8950d 100644
--- a/IedWorkspaceActionsCardBehavior.cs
+++ b/IedWorkspaceActionsCardBehavior.cs
@@ -14,7 +14,7 @@ namespace ArIED61850Tester;
internal static class IedWorkspaceActionsCardBehavior
{
private const string LegacyToolTip = "Configure Signals";
- private const string ActionsToolTip = "IED Actions — Static DataSet, Select Signals, RCB Engineering, COMTRADE, Browse Offline";
+ private const string ActionsToolTip = "IED Actions — DataSet Signals, Signal Catalog, RCB Engineering, COMTRADE, Browse Offline";
[ModuleInitializer]
internal static void Initialize()
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 69eae95b6..2020c6e4a 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -766,7 +766,7 @@ private async Task ConnectAndConfigureDeviceAsync(
if (openWizard && device.SignalCount > 0)
{
if ((selectDevice || ReferenceEquals(SelectedDevice, device)) && !_signalSelectionWizardOpen)
- await OpenSignalSelectionWizardAsync(device, restoredCount);
+ await OpenIedWorkspaceActionsAsync(device);
else
SetStatus($"{device.Name}: discovery complete. Use the edit icon on its IED card to review {restoredCount} restored selection(s).");
}
diff --git a/SclSignalSelectionModeWindow.xaml b/SclSignalSelectionModeWindow.xaml
index f4668d997..573a3c11d 100644
--- a/SclSignalSelectionModeWindow.xaml
+++ b/SclSignalSelectionModeWindow.xaml
@@ -52,14 +52,14 @@
diff --git a/Services/Iec61850DataSetCompletenessDiagnostic.cs b/Services/Iec61850DataSetCompletenessDiagnostic.cs
index 9b44624a1..1bf59446f 100644
--- a/Services/Iec61850DataSetCompletenessDiagnostic.cs
+++ b/Services/Iec61850DataSetCompletenessDiagnostic.cs
@@ -24,6 +24,8 @@ public sealed record Iec61850DataSetCompletenessSnapshot(
public bool IsComplete => StaticMemberCount == RepresentedCount && MissingCount == 0;
public IReadOnlyList DataSets { get; init; }
= Array.Empty();
+ public IReadOnlyList PrimaryUnresolvedReferences { get; init; }
+ = Array.Empty();
public string Summary =>
$"DataSets={DataSetCount:N0}; static members={StaticMemberCount:N0}; semantic descriptors={MandatoryInventoryCount:N0}; " +
@@ -89,15 +91,28 @@ public static Iec61850DataSetCompletenessSnapshot Evaluate(
dataSetMissing));
}
+ var unresolvedPrimaryReferences = mandatory
+ .Where(descriptor => descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved)
+ .SelectMany(descriptor => descriptor.DataSetMemberships.Count > 0
+ ? descriptor.DataSetMemberships.Select(membership =>
+ $"{membership.DataSetReference}[{membership.MemberIndex}] -> " +
+ $"{FirstNonEmpty(membership.CanonicalMemberReference, membership.OriginalMemberReference, descriptor.DesignReference)}")
+ : new[] { FirstNonEmpty(descriptor.DesignReference, descriptor.ObservedReference, "") })
+ .Where(reference => !string.IsNullOrWhiteSpace(reference))
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(reference => reference, StringComparer.Ordinal)
+ .ToArray();
+
return new Iec61850DataSetCompletenessSnapshot(
model.DataSets.Count,
staticMemberCount,
mandatory.Count,
represented,
- mandatory.Count(descriptor => descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved),
+ unresolvedPrimaryReferences.Length,
missing)
{
- DataSets = dataSetSnapshots
+ DataSets = dataSetSnapshots,
+ PrimaryUnresolvedReferences = unresolvedPrimaryReferences
};
}
@@ -117,6 +132,11 @@ public static IEnumerable FormatReportLines(Iec61850DataSetCompletenessS
yield return $" {dataSet.Reference}: {dataSet.RepresentedCount:N0}/{dataSet.StaticMemberCount:N0} represented • {dataSet.MissingCount:N0} missing";
}
+ foreach (var reference in snapshot.PrimaryUnresolvedReferences.Take(16))
+ yield return $" UNRESOLVED PRIMARY : {reference}";
+ if (snapshot.PrimaryUnresolvedReferences.Count > 16)
+ yield return $" ... unresolved : {snapshot.PrimaryUnresolvedReferences.Count - 16:N0} more member(s)";
+
if (snapshot.MissingCount == 0 || maxMissing <= 0)
yield break;
@@ -146,6 +166,9 @@ public static IEnumerable FormatReportLines(Iec61850DataSetCompletenessS
yield return $" ... : {snapshot.MissingCount - emitted:N0} more missing member(s)";
}
+ private static string FirstNonEmpty(params string?[] values)
+ => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
+
private static string Literal(string? reference)
=> (reference ?? string.Empty).Trim();
}
diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs
index cb5a7d36b..1abc8af02 100644
--- a/Services/Iec61850MonitorRuntime.cs
+++ b/Services/Iec61850MonitorRuntime.cs
@@ -1136,7 +1136,7 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
{
var effectiveUpdates = session.StaticDataSetReportOnly
? session.StaticReportProjection.Project(
- session.Device.LiveDiscoveryModel ?? session.Device.SclWorkspace?.DesignModel,
+ session.Device.SclWorkspace?.DesignModel ?? session.Device.LiveDiscoveryModel,
session.Points.Values.ToArray(),
sourceUpdate)
: new[] { sourceUpdate };
diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs
index 3c5c17940..ccf683311 100644
--- a/Services/NativeIec61850Client.SclAssisted.cs
+++ b/Services/NativeIec61850Client.SclAssisted.cs
@@ -256,7 +256,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut
_liveModel = preparation.InitialReadDesign.Model;
var reportInventory = BuildTrustedSclReportInventory(_liveModel);
- var dataSetDirectories = BuildTrustedSclDataSetDirectories(_liveModel);
+ var dataSetDirectories = BuildModelDataSetDirectories(_liveModel, "TrustedScl");
foreach (var directory in dataSetDirectories)
{
_trustedSclDataSetDirectories[
@@ -455,8 +455,9 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory(
return inventory;
}
- private static IReadOnlyList BuildTrustedSclDataSetDirectories(
- LiveIedModelDiscoveryDocument model)
+ private static IReadOnlyList BuildModelDataSetDirectories(
+ LiveIedModelDiscoveryDocument model,
+ string source)
=> model.DataSets
.Select(dataSet =>
{
@@ -467,7 +468,7 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory(
dataSet.Name);
var members = dataSet.Members
.OrderBy(member => member.Index)
- .Select(member => BuildTrustedSclDataSetMember(member, domain))
+ .Select(member => BuildModelDataSetMember(member, domain, source))
.ToArray();
return new ArMms.MmsDataSetDirectoryResult
{
@@ -478,14 +479,15 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory(
IsDeletable = dataSet.IsDeletable,
Members = members,
Message =
- $"Trusted SCL DataSet authority: {dataSet.Reference} has {members.Length} ordered member(s); no network directory request was sent."
+ $"{source} DataSet authority: {dataSet.Reference} has {members.Length} ordered member(s); model order is preserved."
};
})
.ToArray();
- private static ArMms.MmsDataSetDirectoryMember BuildTrustedSclDataSetMember(
+ private static ArMms.MmsDataSetDirectoryMember BuildModelDataSetMember(
LiveIedDataSetMemberModel member,
- string fallbackDomain)
+ string fallbackDomain,
+ string source)
{
var mmsReference = member.MmsReference?.Trim() ?? string.Empty;
var slash = mmsReference.IndexOf('/');
@@ -507,7 +509,7 @@ private static ArMms.MmsDataSetDirectoryMember BuildTrustedSclDataSetMember(
FunctionalConstraint = member.FunctionalConstraint,
LogicalNode = logicalNode,
DataObjectPath = dataObjectPath,
- Source = "TrustedScl",
+ Source = source,
Confidence = 100
};
}
diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs
index fbc918f4c..5b0495d10 100644
--- a/Services/NativeIec61850Client.StaticDataSetReporting.cs
+++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs
@@ -60,6 +60,14 @@ public async Task BuildStaticDataSetReportPlan
? "opened SCL design model"
: "live discovery model (online-only)";
+ // The model that created the operator-visible DataSet inventory is also the semantic
+ // member-order authority for InformationReport mapping. Live directory evidence is
+ // verification only; replacing these members with a second raw network projection can
+ // re-introduce positional/type ambiguity after discovery has already canonicalized it.
+ var modelDataSetDirectories = BuildModelDataSetDirectories(
+ projectionModel,
+ device.SclWorkspace?.DesignModel is not null ? "SclDesignModel" : "LiveDiscoveryModel");
+
// Fresh report discovery is verification, not permission policy. In particular we
// deliberately do NOT require the adaptive Hybrid availability gate to classify a
// configured BRCB as Available. Some perfectly usable servers omit enough reservation
@@ -235,20 +243,31 @@ public async Task BuildStaticDataSetReportPlan
if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference))
liveRcb.DataSetReference = dataSetReference;
- var directories = await RunMmsOperationAsync(
- () => _session.GetDataSetDirectoriesAsync(
- new[] { dataSetReference },
- discovery.IedDirectory,
- cancellationToken),
- cancellationToken).ConfigureAwait(false);
- var directory = directories.SingleOrDefault(result =>
+ var modelDirectory = modelDataSetDirectories.SingleOrDefault(result =>
result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference));
+ if (modelDirectory is null || modelDirectory.Members.Count == 0)
+ {
+ warnings.Add(
+ $"{dataSetReference}: canonical model has no ordered DataSet member list. RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled.");
+ continue;
+ }
+
+ // Smart Discovery already captured GetNamedVariableListAttributes in this same
+ // association. Reuse that wire evidence instead of issuing a second directory read,
+ // then require exact count/order identity against the canonical model.
+ var liveDirectory = discovery.DataSetDirectories.SingleOrDefault(result =>
+ result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference));
+ if (liveDirectory is null || liveDirectory.Members.Count == 0)
+ {
+ warnings.Add(
+ $"{dataSetReference}: discovery snapshot has no ordered live DataSet directory. RCB was not armed; re-scan is required and MMS process polling remains disabled.");
+ continue;
+ }
- if (directory is null || directory.Members.Count == 0)
+ if (!TryVerifyStaticDataSetMemberOrder(modelDirectory, liveDirectory, out var directoryMismatch))
{
- var detail = directories.FirstOrDefault()?.Message ?? "no directory response";
warnings.Add(
- $"{dataSetReference}: live DataSet directory could not prove an ordered non-empty member list ({detail}). RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled.");
+ $"{dataSetReference}: canonical/live DataSet member order mismatch ({directoryMismatch}). RCB was not armed; unsafe positional report projection and MMS process fallback were both refused.");
continue;
}
@@ -307,12 +326,12 @@ public async Task BuildStaticDataSetReportPlan
Status = ArMms.MmsReportSubscriptionPlanStatus.ReadyRequiresWrite,
ReportControl = liveRcb,
DataSetReference = dataSetReference,
- Members = directory.Members,
+ Members = modelDirectory.Members,
DynamicPoints = Array.Empty(),
Steps = new[]
{
$"Verify authoritative configured RCB {configured.Reference} as live object {concreteReportReference}.",
- $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).",
+ $"Use canonical ordered DataSet members {dataSetReference} ({modelDirectory.Members.Count} members), verified against the discovery snapshot.",
"Install InformationReport receiver before enabling the RCB.",
"Use client-compatible BRCB reservation when ResvTms is exposed, enable RptEna, then request GI after receiver registration.",
"Map report values by ordered DataSet member index; never substitute cyclic MMS process reads."
@@ -423,22 +442,18 @@ public async Task StartStaticDataSetReportMonito
}
var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members);
- var attempt = await RunMmsOperationAsync(
- () => _session.StartPersistentReportMonitorClientCompatibleAsync(
+ var start = await RunMmsOperationAsync(
+ () => _session.StartConfiguredStaticReportMonitorAsync(
subscription,
triggerGeneralInterrogation: true,
- deleteDynamicDataSetOnStop: false,
- discovery.IedDirectory,
cancellationToken),
cancellationToken).ConfigureAwait(false);
- var start = attempt.StartResult;
var warnings = start.Warnings
.Concat(subscription.Warnings)
- .Concat(attempt.CleanupWarnings)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
- if (!attempt.IsSuccess || start.Session is null)
+ if (!start.IsSuccess || start.Session is null)
{
return new NativeReportMonitorStartResult
{
@@ -451,9 +466,9 @@ public async Task StartStaticDataSetReportMonito
UsedDynamicDataSet = false,
DynamicAttempted = false,
DynamicAttemptState = "NotApplicable",
- FailureReason = attempt.FailureReason.ToString(),
- CleanupAttempted = attempt.CleanupAttempted,
- CleanupSucceeded = attempt.CleanupSucceeded,
+ FailureReason = "ConfiguredStaticActivationFailed",
+ CleanupAttempted = false,
+ CleanupSucceeded = true,
ReportControlReference = plan.ReportControlReference,
DataSetReference = plan.DataSetReference,
CoveredReferences = coveredReferences,
@@ -508,6 +523,43 @@ private static NativeHybridReportPlanningResult StaticPlanningUnavailable(
UncoveredSignalCount = points.Count
};
+ private static bool TryVerifyStaticDataSetMemberOrder(
+ ArMms.MmsDataSetDirectoryResult modelDirectory,
+ ArMms.MmsDataSetDirectoryResult liveDirectory,
+ out string mismatch)
+ {
+ mismatch = string.Empty;
+ if (modelDirectory.Members.Count != liveDirectory.Members.Count)
+ {
+ mismatch = $"count model={modelDirectory.Members.Count}, live={liveDirectory.Members.Count}";
+ return false;
+ }
+
+ for (var index = 0; index < modelDirectory.Members.Count; index++)
+ {
+ var modelMember = modelDirectory.Members[index];
+ var liveMember = liveDirectory.Members[index];
+ var mmsMatches =
+ !string.IsNullOrWhiteSpace(modelMember.MmsReference) &&
+ !string.IsNullOrWhiteSpace(liveMember.MmsReference) &&
+ SameStaticReference(modelMember.MmsReference, liveMember.MmsReference);
+ var userMatches =
+ !string.IsNullOrWhiteSpace(modelMember.UserReference) &&
+ !string.IsNullOrWhiteSpace(liveMember.UserReference) &&
+ SameStaticReference(modelMember.UserReference, liveMember.UserReference);
+
+ if (mmsMatches || userMatches)
+ continue;
+
+ mismatch =
+ $"member[{index}] model={modelMember.UserReference} ({modelMember.MmsReference}), " +
+ $"live={liveMember.UserReference} ({liveMember.MmsReference})";
+ return false;
+ }
+
+ return true;
+ }
+
private static int StaticRcbAvailabilityRank(ArMms.MmsRcbOperationalAvailability availability)
=> availability switch
{
diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs
index 04a657e42..91cc9cf00 100644
--- a/Services/NativeIec61850Client.cs
+++ b/Services/NativeIec61850Client.cs
@@ -62,6 +62,7 @@ public async Task ConnectAsync(string ipAddress, int port, CancellationToken can
LastConnectionTechnicalSummary = string.Empty;
_lastDiscovery = null;
_liveModel = null;
+ ResetSmartDiscoveryAuthority(); // __P0_5C_CONNECT_RESET__
ClearCanonicalModel();
_reportMonitorSessions.Clear();
_reportMonitorCoverage.Clear();
@@ -127,6 +128,9 @@ public Iec61850DeviceDiagnosticSnapshot CaptureDiagnosticSnapshot(
public async Task> DiscoverSignalsAsync(CancellationToken cancellationToken, IProgress? progress = null)
{
+ if (SmartDiscoveryCaptureModeEnabled)
+ return await DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress).ConfigureAwait(false);
+
LastDiscoverySummary = string.Empty;
cancellationToken.ThrowIfCancellationRequested();
progress?.Report(new IedDiscoveryProgress(
@@ -1950,6 +1954,8 @@ private static Iec61850ControlCommandResult ControlWireUnknownFailure(
public async ValueTask DisposeAsync()
{
+ ResetSmartDiscoveryAuthority(); // __P0_5C_DISPOSE_RESET__
+
await DisposeControlSessionsAsync().ConfigureAwait(false);
await StopReportMonitorsAsync().ConfigureAwait(false);
await _mmsIoGate.WaitAsync().ConfigureAwait(false);
@@ -1979,7 +1985,9 @@ public async ValueTask DisposeAsync()
var service = new ArControl.Iec61850ControlService();
var opened = await RunMmsOperationAsync(
- () => service.OpenAsync(_session, signal.ObjectReference, cancellationToken),
+ () => _lastDiscovery != null
+ ? service.OpenAsync(_session, signal.ObjectReference, _lastDiscovery.Snapshot.DomainVariables, cancellationToken)
+ : service.OpenAsync(_session, signal.ObjectReference, cancellationToken),
cancellationToken).ConfigureAwait(false);
_controlSessions[key] = opened;
return opened;
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index 360e72a96..a99f5514c 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,9 +2,9 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
- "commit": "648124097621046f5f127ceb1cf853fea54db730",
- "sourcePullRequest": 135,
- "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 648124097621046f5f127ceb1cf853fea54db730 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged. P2 makes instance-value identity exact-case end-to-end: ARSAS trusted-SCL caching uses StringComparer.Ordinal and reports projectedUniqueValues/cacheLoss; engine TypeSpecification member resolution and canonical DO/DA instance-value targeting are case-sensitive so legal paths such as tracking t/T cannot collapse or cross-resolve. R10 physical acceptance passed on AA1E1F06R4: Ed2 and Ed1 both reached projectionErrors=0 and cacheLoss=0, all planned reads succeeded, 58/58 runtime points were report-backed, and actual InformationReport traffic was observed. The exact tested commit 9935d6902d786cc69b299260fe36b835944d5e81 and merged main commit 648124097621046f5f127ceb1cf853fea54db730 have the identical source tree 1cf7e08f333f24994625e8fe8416dbd0a16195b1.",
+ "commit": "375543da77ea515757407e7587e08a259df9653a",
+ "sourcePullRequest": 137,
+ "purpose": "R7 physical SCL repair pin on the complete PR #134 smart-discovery performance head. Accepted remote AP-title/AE/PSEL/SSEL remain decoded from the exact association request bytes accepted by the IED and TSEL remains bound to the accepted COTP destination selector. Physical AA1E1F06R4 evidence additionally proved that indexed RCB siblings may differ in writable/current BufTm, IntgPd, TrgOps and OptFlds while still representing one logical SCL ReportControl; engine head 648124097621046f5f127ceb1cf853fea54db730 preserves DataSet/ConfRev/domain/LN/buffered identity while allowing that runtime-mutable setting drift. ARSAS consumer-side reconnect must reproduce the proven native calling identity byte-for-byte and canonical save uses full-model SCL with prefixed-LN identity, CDC-aware WYE/DEL/SEQ SDO and FC ownership, plus exact standard TCTR/TVTR/LTIM/EEName/MltLev CDC authority and Edition-2-only LTRK service-tracking CDCs with Edition-1 schema downgrade protection; physical R8 reuse evidence additionally requires exact MMS TypeSpecification declaration order through canonical model/SCL reload, treats SG/SE as setting data rather than control blocks, and maps MHAI THD phase groups as WYE/CMV; safety is enforced by bounded FC-read policy rather than deleting discovered model leaves. Production promotion remains fail-closed and still requires fresh physical authority. R9 reuse lock additionally preserves rptID-backed preallocated singleton RCB indexing, case-distinct canonical instance values, and compile-safe logical RCB projection; unresolved 46 trusted-SCL projection errors remain a fail-open diagnostic gap but a fail-closed promotion gap until physical evidence reaches zero. P1 trusted-SCL projection repair removes cross-DO positional dependence for multi-DO CF structures: SCL LNodeType order is not treated as MMS FC-structure order, so CF hydration is split into exact DO-scoped structured Reads and remains batched/bounded. P0 structural discovery remains unchanged. P2 makes instance-value identity exact-case end-to-end: ARSAS trusted-SCL caching uses StringComparer.Ordinal and reports projectedUniqueValues/cacheLoss; engine TypeSpecification member resolution and canonical DO/DA instance-value targeting are case-sensitive so legal paths such as tracking t/T cannot collapse or cross-resolve. R10 physical acceptance passed on AA1E1F06R4: Ed2 and Ed1 both reached projectionErrors=0 and cacheLoss=0, all planned reads succeeded, 58/58 runtime points were report-backed, and actual InformationReport traffic was observed. The exact tested commit 9935d6902d786cc69b299260fe36b835944d5e81 and merged main commit 648124097621046f5f127ceb1cf853fea54db730 have the identical source tree 1cf7e08f333f24994625e8fe8416dbd0a16195b1. Candidate live-discovery static-parity repair pins PR #137 only to expose the same configured-static activation sequence through a source-neutral API; this candidate is not physical acceptance authority and must not replace the R10 physical baseline until retested.",
"previousTrialPin": {
"commit": "4467124775d8d9d76f3db194f9fbfd97144767a8",
"sourcePullRequest": 134,
diff --git a/evidence/iedscout-convergence-target.json b/evidence/iedscout-convergence-target.json
index a853fd0ea..20bcde006 100644
--- a/evidence/iedscout-convergence-target.json
+++ b/evidence/iedscout-convergence-target.json
@@ -29,7 +29,7 @@
"repository": "masarray/arsas",
"pullRequest": 324,
"branch": "test/smart-ied-discovery-pr134",
- "status": "physical-proven-merge-ready"
+ "status": "physical-proven-effective-field-build; source-route-promotion-candidate"
}
},
"physicalReference": {
@@ -119,7 +119,7 @@
"promotion": {
"productionPromoted": false,
"mergeAllowedBeforePhysicalRetest": false,
- "physicalRetestRequired": false,
+ "physicalRetestRequired": true,
"requiredEvidence": [
"new ARSAS PCAP",
"new generated Ed2 IID",
@@ -129,10 +129,11 @@
"Ed1 SCL-assisted reuse diagnostic with projectionErrorSamples",
"same-relay comparison against IEDScout"
],
- "mergeAllowedAfterPhysicalRetest": true,
- "physicalRetestPassed": true,
+ "mergeAllowedAfterPhysicalRetest": false,
+ "physicalRetestPassed": false,
"physicalRetestDate": "2026-09-19",
- "note": "R10 physical discovery/save/reopen evidence passed for the exact ARSAS/engine source tree. Merge is allowed; production promotion remains a separate release decision."
+ "note": "Historical R10 physical evidence remains valid for the effective field artifact that included the build-time smart-route transform. This candidate promotes that transform into normal source and also changes live static-report orchestration, so fresh physical acceptance is mandatory before merge or publication.",
+ "candidateRetestStatus": "required-after-smart-route-source-promotion-and-live-static-parity-repair"
},
"legacyPolicy": {
"rule": "Historical PRs may remain as provenance only; they must not be used as new branch bases after an active convergence authority supersedes them.",
@@ -204,7 +205,11 @@
"appHead": "eb8eb13d491f9aa265205852b8a4bab07af440ff",
"engineTestedHead": "9935d6902d786cc69b299260fe36b835944d5e81",
"engineMergedMain": "648124097621046f5f127ceb1cf853fea54db730",
- "engineTree": "1cf7e08f333f24994625e8fe8416dbd0a16195b1"
+ "engineTree": "1cf7e08f333f24994625e8fe8416dbd0a16195b1",
+ "buildTransformScript": "scripts/enable-smart-discovery-capture.ps1",
+ "effectiveDiscoveryEntrypoint": "DiscoverSignalsAsync -> DiscoverSignalsSmartForCaptureAsync",
+ "unmodifiedSourceContainedSmartRoute": false,
+ "provenanceCorrection": "The app commit identifies the repository revision, but the physically tested field artifact also included the workflow build transform above. Commit/tree equality alone is not sufficient to identify equivalent discovery behavior."
},
"discovery": {
"associations": 1,
diff --git a/landing/release-notes.json b/landing/release-notes.json
index 0b00ab1aa..75024309f 100644
--- a/landing/release-notes.json
+++ b/landing/release-notes.json
@@ -8,13 +8,13 @@
"summaryId": "ARSAS 1.6.38 adalah stable release Windows terverifikasi terbaru. Identitas paket, link download, checksum, dan publication evidence disinkronkan otomatis dari GitHub Release bertag.",
"highlights": [
"Polish WPF typography with embedded Inter and smooth rendering",
- "convergence(discovery/scl): IEDScout-parity discovery and usable SCL",
+ "convergence(discovery/scl): reference-parity discovery and usable SCL",
"fix(ci): restore R10 post-merge release gates",
"The verified Windows installer is published as the stable ARSAS 1.6.38 package."
],
"highlightsId": [
"Perubahan rilis: Polish WPF typography with embedded Inter and smooth rendering",
- "Perubahan rilis: convergence(discovery/scl): IEDScout-parity discovery and usable SCL",
+ "Perubahan rilis: convergence(discovery/scl): reference-parity discovery and usable SCL",
"Perubahan rilis: fix(ci): restore R10 post-merge release gates",
"Installer Windows terverifikasi dipublikasikan sebagai paket stabil ARSAS 1.6.38."
],
diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs
index 4096169c8..ad84dc30e 100644
--- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs
+++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs
@@ -11,10 +11,13 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling()
Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal);
Assert.Contains("configurationModel.ReportControls", source, StringComparison.Ordinal);
Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal);
- Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal);
+ Assert.Contains("BuildModelDataSetDirectories", source, StringComparison.Ordinal);
+ Assert.Contains("discovery.DataSetDirectories", source, StringComparison.Ordinal);
+ Assert.Contains("TryVerifyStaticDataSetMemberOrder", source, StringComparison.Ordinal);
Assert.Contains("MmsReportSubscriptionPlanStatus.ReadyRequiresWrite", source, StringComparison.Ordinal);
+ Assert.Contains("StartConfiguredStaticReportMonitorAsync", source, StringComparison.Ordinal);
Assert.Contains("triggerGeneralInterrogation: true", source, StringComparison.Ordinal);
- Assert.Contains("deleteDynamicDataSetOnStop: false", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartPersistentReportMonitorClientCompatibleAsync", source, StringComparison.Ordinal);
Assert.Contains("PollingPointKeys = Array.Empty()", source, StringComparison.Ordinal);
Assert.Contains("PollingFallbackSignalCount = 0", source, StringComparison.Ordinal);
@@ -44,21 +47,36 @@ public void TrustedSclStaticPath_RequestsOneShotGi_WhileSafeTrialRemainsReadOnly
}
[Fact]
- public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory()
+ public void StaticPath_RequiresConfiguredRcbFamilyAndCanonicalOrderVerifiedByDiscoverySnapshot()
{
var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal);
Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal);
Assert.Contains("arbitrary same-DataSet", source, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal);
- Assert.Contains("directory.Members", source, StringComparison.Ordinal);
+ Assert.Contains("canonical model has no ordered DataSet member list", source, StringComparison.Ordinal);
+ Assert.Contains("discovery snapshot has no ordered live DataSet directory", source, StringComparison.Ordinal);
+ Assert.Contains("canonical/live DataSet member order mismatch", source, StringComparison.Ordinal);
+ Assert.Contains("Members = modelDirectory.Members", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("Members = liveDirectory.Members", source, StringComparison.Ordinal);
Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal);
Assert.Contains("authoritative configuration binds", source, StringComparison.Ordinal);
Assert.Contains("live DatSet reports", source, StringComparison.Ordinal);
Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal);
}
+ [Fact]
+ public void StaticPath_DoesNotIssueSecondDataSetDirectoryReadOrUseRawLiveMembersAsProjectionAuthority()
+ {
+ var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
+
+ Assert.Contains("discovery.DataSetDirectories.SingleOrDefault", source, StringComparison.Ordinal);
+ Assert.Contains("Members = modelDirectory.Members", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("Members = liveDirectory.Members", source, StringComparison.Ordinal);
+ Assert.Contains("member[{index}]", source, StringComparison.Ordinal);
+ }
+
[Fact]
public void StaticPath_SclAuthorityCannotBeDisplacedByLivePeerRcb()
{
diff --git a/tests/ARSAS.Tests/LiveDiscoveryStaticParityRegressionTests.cs b/tests/ARSAS.Tests/LiveDiscoveryStaticParityRegressionTests.cs
new file mode 100644
index 000000000..4cfd44d10
--- /dev/null
+++ b/tests/ARSAS.Tests/LiveDiscoveryStaticParityRegressionTests.cs
@@ -0,0 +1,78 @@
+namespace ARSAS.Tests;
+
+public sealed class LiveDiscoveryStaticParityRegressionTests
+{
+ [Fact]
+ public void Discovery_EntersSharedPostModelChooser()
+ {
+ var source = Read("MainWindow.xaml.cs");
+ var start = source.IndexOf(
+ "private async Task ConnectAndConfigureDeviceAsync",
+ StringComparison.Ordinal);
+ var end = source.IndexOf(
+ "private async Task ConnectUsingSavedModelAsync",
+ start,
+ StringComparison.Ordinal);
+ Assert.True(start >= 0 && end > start);
+
+ var discovery = source[start..end];
+ Assert.Contains("await OpenIedWorkspaceActionsAsync(device);", discovery, StringComparison.Ordinal);
+ Assert.DoesNotContain("OpenSignalSelectionWizardAsync(device, restoredCount)", discovery, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void DataSetSignals_UsesCanonicalMembersAndConfiguredStaticActivation()
+ {
+ var staticPath = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
+
+ Assert.Contains("BuildModelDataSetDirectories", staticPath, StringComparison.Ordinal);
+ Assert.Contains("Members = modelDirectory.Members", staticPath, StringComparison.Ordinal);
+ Assert.Contains("discovery.DataSetDirectories", staticPath, StringComparison.Ordinal);
+ Assert.Contains("TryVerifyStaticDataSetMemberOrder", staticPath, StringComparison.Ordinal);
+ Assert.Contains("StartConfiguredStaticReportMonitorAsync", staticPath, StringComparison.Ordinal);
+ Assert.DoesNotContain("GetDataSetDirectoriesAsync", staticPath, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartPersistentReportMonitorClientCompatibleAsync", staticPath, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void StaticProjection_UsesSameAuthorityPrecedenceAsPlanning()
+ {
+ var runtime = Read("Services/Iec61850MonitorRuntime.cs");
+
+ Assert.Contains(
+ "session.Device.SclWorkspace?.DesignModel ?? session.Device.LiveDiscoveryModel",
+ runtime,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "session.Device.LiveDiscoveryModel ?? session.Device.SclWorkspace?.DesignModel",
+ runtime,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Chooser_UsesSourceNeutralLabels()
+ {
+ var xaml = Read("SclSignalSelectionModeWindow.xaml");
+
+ Assert.Contains("DataSet Signals", xaml, StringComparison.Ordinal);
+ Assert.Contains("Static report-only", xaml, StringComparison.Ordinal);
+ Assert.Contains("Signal Catalog", xaml, StringComparison.Ordinal);
+ }
+
+ private static string Read(string relativePath)
+ => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal);
+
+ private static string FindRepoFile(string relativePath)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, relativePath);
+ if (File.Exists(candidate))
+ return candidate;
+ directory = directory.Parent;
+ }
+
+ throw new FileNotFoundException(relativePath);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs
index 0e002ac18..5bba3cb36 100644
--- a/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs
+++ b/tests/ARSAS.Tests/SmartDiscoveryAssociationSingleFlightRegressionTests.cs
@@ -32,6 +32,7 @@ public void P05c_ReconnectAndDispose_InvalidateGenerationAndBlockStalePublish()
var lifecycle = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryLifecycle.cs"));
var capture = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.SmartDiscoveryCapture.cs"));
var patcher = File.ReadAllText(FindRepoFile("scripts/enable-smart-discovery-capture.ps1"));
+ var native = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.cs"));
Assert.Contains("_smartDiscoveryAssociationGeneration++", lifecycle, StringComparison.Ordinal);
Assert.Contains("generation != _smartDiscoveryAssociationGeneration", lifecycle, StringComparison.Ordinal);
@@ -40,6 +41,10 @@ public void P05c_ReconnectAndDispose_InvalidateGenerationAndBlockStalePublish()
Assert.Contains("__P0_5C_CONNECT_RESET__", patcher, StringComparison.Ordinal);
Assert.Contains("__P0_5C_DISPOSE_RESET__", patcher, StringComparison.Ordinal);
Assert.Contains("ResetSmartDiscoveryAuthority(); // __P0_5C_DISPOSE_RESET__", patcher, StringComparison.Ordinal);
+ Assert.Contains("return await DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress)", native, StringComparison.Ordinal);
+ Assert.Contains("ResetSmartDiscoveryAuthority(); // __P0_5C_CONNECT_RESET__", native, StringComparison.Ordinal);
+ Assert.Contains("ResetSmartDiscoveryAuthority(); // __P0_5C_DISPOSE_RESET__", native, StringComparison.Ordinal);
+ Assert.Contains("_lastDiscovery.Snapshot.DomainVariables", native, StringComparison.Ordinal);
}
[Fact]