From 6a0cbaf2d886de020c3049f734b7d5d6a8c8fed3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:14:30 +0700 Subject: [PATCH 01/23] fix(model): allow bracket-leading strings only with verified MMS kind and model type --- Models/IedBrowserReadValuePolicy.cs | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Models/IedBrowserReadValuePolicy.cs b/Models/IedBrowserReadValuePolicy.cs index b8b3a3524..b03904cb1 100644 --- a/Models/IedBrowserReadValuePolicy.cs +++ b/Models/IedBrowserReadValuePolicy.cs @@ -59,6 +59,40 @@ public static bool TryFormatVerifiedEmptyVisible( return false; } + /// + /// A square/curly-bracket prefix can be literal text in a verified MMS + /// VisibleString/MMSString scalar. Permit it ONLY with direct decoded wire + /// kind proof and an exactly compatible verified model declaration. + /// Never treat rendered arrays, structures or unknown BER tags as strings. + /// + public static bool TryAcceptVerifiedMmsString( + string? candidate, string? modelType, + AR.Iec61850.Mms.MmsDataKind? verifiedWireKind, + out string display, out string rejection) + { + if (TryAcceptDisplay(candidate, out display, out rejection)) + return true; + if (display.Length is 0 or > 512 || + !(display.StartsWith("[", StringComparison.Ordinal) || + display.StartsWith("{", StringComparison.Ordinal))) + return false; + + var declared = (modelType ?? "").Trim(); + var compatible = verifiedWireKind switch + { + AR.Iec61850.Mms.MmsDataKind.VisibleString => + declared.StartsWith("VisString", StringComparison.OrdinalIgnoreCase) || + declared.Equals("visible-string", StringComparison.OrdinalIgnoreCase), + AR.Iec61850.Mms.MmsDataKind.MmsString => + declared.StartsWith("MmsString", StringComparison.OrdinalIgnoreCase) || + declared.Equals("mms-string", StringComparison.OrdinalIgnoreCase), + _ => false + }; + if (!compatible) return false; + rejection = ""; + return true; + } + public static bool TryAcceptDisplay( string? candidate, out string display, out string rejection) { From ab2a8a2da3ef35237a0069e1e51255d8cf3b8c38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:14:33 +0700 Subject: [PATCH 02/23] feat(model): preserve primitive MMS string-kind proof with projected reads --- Services/Iec61850ReadValue.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Services/Iec61850ReadValue.cs b/Services/Iec61850ReadValue.cs index b13e7f95e..a100b669e 100644 --- a/Services/Iec61850ReadValue.cs +++ b/Services/Iec61850ReadValue.cs @@ -14,6 +14,11 @@ public sealed class Iec61850ReadValue public string ReadReference { get; init; } = string.Empty; public string Projection { get; init; } = string.Empty; + // Positive wire-kind proof, not inferred from the rendered display. Only + // direct, primitive MMS string results with matching projected CLR text + // receive this marker. A parent structure can never claim this proof. + public MmsDataKind? VerifiedStringWireKind { get; internal set; } + // Authoritative engine-owned UTC-Time evidence. Available only when // a direct MMS .t read preserved the original eight wire bytes. public Iec61850UtcTimeEvidence? UtcTimeEvidence { get; internal set; } From c5d094ce0d7faf1adf12d0b5c3682b09964e6039 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:14:37 +0700 Subject: [PATCH 03/23] feat(model): attach exact primitive MMS string proof during decoding --- Services/NativeIec61850Client.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 1e41e14dc..a9342fc97 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1129,6 +1129,12 @@ private static string BuildDynamicDataSetName(ReportControlPlan plan) Iec61850ReadValue? projected, ArMms.MmsDataValue? raw, string reference) { if (projected is null || raw is null) return projected; + if (raw.Kind is ArMms.MmsDataKind.VisibleString or ArMms.MmsDataKind.MmsString && + raw.Value is string decoded && + projected.Value is string scalar && + string.Equals(decoded, scalar, StringComparison.Ordinal) && + string.Equals(projected.SourceReference, reference, StringComparison.Ordinal)) + projected.VerifiedStringWireKind = raw.Kind; if ((reference.EndsWith(".q", StringComparison.Ordinal) || reference.EndsWith("$q", StringComparison.Ordinal)) && raw.Kind == ArMms.MmsDataKind.BitString && From 8d074dfa728b438ec4ae6aec84267d4c40acfc5b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:15:06 +0700 Subject: [PATCH 04/23] feat(model): carry typed string MMS proof through association-fenced read result ledger --- Models/IedBrowserLiveValues.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Models/IedBrowserLiveValues.cs b/Models/IedBrowserLiveValues.cs index 594988f85..ad2ef9e65 100644 --- a/Models/IedBrowserLiveValues.cs +++ b/Models/IedBrowserLiveValues.cs @@ -102,9 +102,13 @@ public bool PublishForAssociation(long association, string reference, string fc, string? value, string? quality, string? timestamp, string source, Iec61850UtcTimeEvidence? utcTimeEvidence = null, MmsQualityEvidence? qualityEvidence = null, - bool? typedBoolean = null) + bool? typedBoolean = null, + AR.Iec61850.Mms.MmsDataKind? verifiedStringWireKind = null, + string? declaredDataType = null) => TryPublish(reference, fc, value, quality, timestamp, source, association, - utcTimeEvidence, qualityEvidence, typedBoolean: typedBoolean); + utcTimeEvidence, qualityEvidence, typedBoolean: typedBoolean, + verifiedStringWireKind: verifiedStringWireKind, + declaredDataType: declaredDataType); /// /// Atomically attach a verified, exact-model-owned q or t Report companion @@ -156,12 +160,15 @@ private bool TryPublish(string reference, string fc, string? value, string? qual string? timestamp, string source, long? expectedAssociation, Iec61850UtcTimeEvidence? utcTimeEvidence, MmsQualityEvidence? qualityEvidence, bool onlyIfNoReport = false, - bool? typedBoolean = null) + bool? typedBoolean = null, + AR.Iec61850.Mms.MmsDataKind? verifiedStringWireKind = null, + string? declaredDataType = null) { if (string.IsNullOrWhiteSpace(reference) || string.IsNullOrWhiteSpace(fc) || string.IsNullOrWhiteSpace(value) || (string.Equals(source, "MMS Read", StringComparison.Ordinal) && - !IedBrowserReadValuePolicy.TryAcceptDisplay(value, out _, out _)) || + !IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + value, declaredDataType, verifiedStringWireKind, out _, out _)) || value.Trim() is "-" or "Not read" or "Unknown") return false; @@ -350,5 +357,6 @@ public sealed record IedBrowserReadResult( string DeviceTimestamp, string Error, Iec61850UtcTimeEvidence? UtcTimeEvidence = null, MmsQualityEvidence? QualityEvidence = null, - bool? TypedBoolean = null); + bool? TypedBoolean = null, + AR.Iec61850.Mms.MmsDataKind? VerifiedStringWireKind = null); public sealed record IedBrowserReadProgress(int Completed, int Total); From 01bddc32d8573bf1eac372f21678e413643a57e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:15:25 +0700 Subject: [PATCH 05/23] fix(model): accept verified bracket-leading MMS string scalar in isolated read --- Services/IedBrowserIsolatedReadService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Services/IedBrowserIsolatedReadService.cs b/Services/IedBrowserIsolatedReadService.cs index fa1e2d056..7c301569d 100644 --- a/Services/IedBrowserIsolatedReadService.cs +++ b/Services/IedBrowserIsolatedReadService.cs @@ -85,13 +85,15 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) cancellationToken.ThrowIfCancellationRequested(); var read = raw as Iec61850ReadValue; var display = raw?.ToString() ?? ""; - if (IedBrowserReadValuePolicy.TryAcceptDisplay( - display, out var accepted, out var reason)) + if (IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + display, target.DataType, read?.VerifiedStringWireKind, + out var accepted, out var reason)) { values.Report(new IedBrowserReadResult(target, accepted, read?.Quality ?? "", read?.DeviceTimestamp ?? "", "", read?.UtcTimeEvidence, read?.QualityEvidence, - read?.Value is bool typed ? typed : null)); + read?.Value is bool typed ? typed : null, + read?.VerifiedStringWireKind)); successful++; } else From cffc67090b494b1cc0287a98e4e691badeb69f0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:15:29 +0700 Subject: [PATCH 06/23] fix(model): carry verified decoded string evidence through Browser UI ingestion --- MainWindow.IedBrowser.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index c1d3a1d49..2cde53614 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -551,8 +551,9 @@ void DrainReadResults() sourceDevice.LiveValues.Association != readAssociation || bindingIndex is not { IsCurrent: true }) return; // superseded association, catalog or read flight - var scalarValid = IedBrowserReadValuePolicy.TryAcceptDisplay( - result.Value, out var safeValue, out var scalarReason); + var scalarValid = IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + result.Value, result.Target.DataType, result.VerifiedStringWireKind, + out var safeValue, out var scalarReason); if (result.Error.Length != 0 || !scalarValid) { failures++; @@ -575,7 +576,9 @@ void DrainReadResults() result.Target.FunctionalConstraint, safeValue, result.Quality, result.DeviceTimestamp, "MMS Read", result.UtcTimeEvidence, result.QualityEvidence, - typedBoolean: result.TypedBoolean); + typedBoolean: result.TypedBoolean, + verifiedStringWireKind: result.VerifiedStringWireKind, + declaredDataType: result.Target.DataType); if (!accepted) return; _iedBrowserReadFailureEvidence?.ClearFailure(result.Target); successes++; From e8f69e1bd91984755ea62b8cfd0b9e79fabaa2b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:16:02 +0700 Subject: [PATCH 07/23] test(model): bracket-leading vendor string and unsafe MMS projection regressions --- .../ARSAS.Tests/P0ModelTypedMmsStringTests.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs diff --git a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs new file mode 100644 index 000000000..d7567830f --- /dev/null +++ b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs @@ -0,0 +1,88 @@ +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class P0ModelTypedMmsStringTests +{ + [Theory] + [InlineData("[IRV:vendor-valid", "VisString255", MmsDataKind.VisibleString)] + [InlineData("{literal vendor text}", "visible-string", MmsDataKind.VisibleString)] + [InlineData("[literal MMS text]", "MmsString128", MmsDataKind.MmsString)] + public void VerifiedDecodedStringsWithBracketPrefixAreNotMistakenForMmsArrays( + string scalar, string declaredType, MmsDataKind wireKind) + { + Assert.False(IedBrowserReadValuePolicy.TryAcceptDisplay(scalar, out _, out _)); + Assert.True(IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + scalar, declaredType, wireKind, out var accepted, out var reason)); + Assert.Equal(scalar, accepted); + Assert.Equal("", reason); + } + + [Theory] + [InlineData("[unverified", "VisString255", null)] + [InlineData("{not a string}", "Int32", MmsDataKind.VisibleString)] + [InlineData("[wrong decoder]", "VisString255", MmsDataKind.MmsString)] + [InlineData("[wrong primitive]", "VisString255", MmsDataKind.Array)] + [InlineData("[unknown declaration]", "", MmsDataKind.VisibleString)] + [InlineData("unknown(tag=0x83)", "VisString255", MmsDataKind.VisibleString)] + [InlineData("Structure(2)", "VisString255", MmsDataKind.VisibleString)] + [InlineData("", "VisString255", MmsDataKind.VisibleString)] + public void UnverifiedArrayLikeAndUnsupportedResultsFailClosed( + string scalar, string modelType, MmsDataKind? kind) + { + Assert.False(IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + scalar, modelType, kind, out _, out _)); + } + + [Fact] + public void ScalarBoundaryRemainsBoundedDespiteWireProof() + { + Assert.False(IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + "[" + new string('x', 513), "VisString600", + MmsDataKind.VisibleString, out _, out _)); + } + + [Fact] + public void LiveLedgerRetainsTypedProofOnlyForCurrentReadAssociation() + { + const string reference = "ZIVAVR/LLN0.NamPlt.swRev"; + var ledger = new IedBrowserLiveValues(); + var epoch = ledger.BeginAssociation(); + Assert.False(ledger.PublishForAssociation(epoch, reference, "DC", "[IRV:firmware", + "", "", "MMS Read")); + Assert.False(ledger.PublishForAssociation(epoch, reference, "DC", "[IRV:firmware", + "", "", "MMS Read", verifiedStringWireKind: MmsDataKind.VisibleString, + declaredDataType: "Int32")); + Assert.True(ledger.PublishForAssociation(epoch, reference, "DC", "[IRV:firmware", + "", "", "MMS Read", verifiedStringWireKind: MmsDataKind.VisibleString, + declaredDataType: "VisString255")); + Assert.True(ledger.TryGet(reference, "DC", out var observed, preferReport: false)); + Assert.Equal("[IRV:firmware", observed.Value); + Assert.Equal("MMS Read", observed.Source); + ledger.BeginAssociation(); + Assert.False(ledger.PublishForAssociation(epoch, reference, "DC", "[IRV:old", + "", "", "MMS Read", verifiedStringWireKind: MmsDataKind.VisibleString, + declaredDataType: "VisString255")); + Assert.False(ledger.TryGet(reference, "DC", out _, preferReport: false)); + } + + [Fact] + public void ReadDecodeProofMustFlowThroughIsolatedReaderAndBrowserIngestion() + { + DirectoryInfo? root = new(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "MainWindow.IedBrowser.cs"))) + root = root.Parent; + Assert.NotNull(root); + var source = File.ReadAllText(Path.Combine(root!.FullName, "MainWindow.IedBrowser.cs")); + var isolated = File.ReadAllText(Path.Combine(root.FullName, + "Services", "IedBrowserIsolatedReadService.cs")); + var native = File.ReadAllText(Path.Combine(root.FullName, + "Services", "NativeIec61850Client.cs")); + Assert.Contains("result.VerifiedStringWireKind", source); + Assert.Contains("declaredDataType: result.Target.DataType", source); + Assert.Contains("read?.VerifiedStringWireKind", isolated); + Assert.Contains("raw.Value is string decoded", native); + Assert.Contains("projected.VerifiedStringWireKind = raw.Kind", native); + } +} From 91035e6d9b77f5747ba151edf4b17b7b6d9b1080 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:17:26 +0700 Subject: [PATCH 08/23] fix(model): forbid parent-DO scalar from masquerading as exact string leaf --- Services/NativeIec61850Client.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index a9342fc97..12e4cd444 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1133,7 +1133,14 @@ private static string BuildDynamicDataSetName(ReportControlPlan plan) raw.Value is string decoded && projected.Value is string scalar && string.Equals(decoded, scalar, StringComparison.Ordinal) && - string.Equals(projected.SourceReference, reference, StringComparison.Ordinal)) + string.Equals(projected.SourceReference, reference, StringComparison.Ordinal) && + // A successful parent DO read is NOT proof of this exact leaf. + // Only the primitive response to the same canonical leaf can + // override the conservative display-only array heuristic. + string.Equals( + IedBrowserLiveValues.CanonicalReference(projected.ReadReference), + IedBrowserLiveValues.CanonicalReference(reference), + StringComparison.Ordinal)) projected.VerifiedStringWireKind = raw.Kind; if ((reference.EndsWith(".q", StringComparison.Ordinal) || reference.EndsWith("$q", StringComparison.Ordinal)) && From 3a5a3f536846eea3a144fb1dd91b1880db970d0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:17:29 +0700 Subject: [PATCH 09/23] test(model): ensure verified primitive text is bound to exact leaf not parent --- tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs index d7567830f..a411873ea 100644 --- a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs +++ b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs @@ -83,6 +83,8 @@ public void ReadDecodeProofMustFlowThroughIsolatedReaderAndBrowserIngestion() Assert.Contains("declaredDataType: result.Target.DataType", source); Assert.Contains("read?.VerifiedStringWireKind", isolated); Assert.Contains("raw.Value is string decoded", native); + Assert.Contains("CanonicalReference(projected.ReadReference)", native); + Assert.Contains("CanonicalReference(reference)", native); Assert.Contains("projected.VerifiedStringWireKind = raw.Kind", native); } } From 4021c74cacc5da3a5edea06d084ccc76a054e6bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:54:13 +0700 Subject: [PATCH 10/23] test(model): update legacy verdict contract to require wire-proven string exception and fail-closed monitoring --- tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs b/tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs index 05eb4f1ce..51baa6dd6 100644 --- a/tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs +++ b/tests/ARSAS.Tests/M214BrowserReadValueVerdictTests.cs @@ -76,9 +76,15 @@ public void BothBrowserReadServicesAndUiRequireTheSameEvidenceVerdict() var isolated = File.ReadAllText(Find("Services/IedBrowserIsolatedReadService.cs")); var shared = File.ReadAllText(Find("Services/Iec61850MonitorRuntime.cs")); var ui = File.ReadAllText(Find("MainWindow.IedBrowser.cs")); - Assert.Contains("IedBrowserReadValuePolicy.TryAcceptDisplay", isolated); + // Both reader and UI now require wire-kind proof when an MMS string + // begins with array/structure-like punctuation. The shared monitoring + // service retains the conservative display-only policy; its untyped + // observations must never receive the explicit-read exception. + Assert.Contains("IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString", isolated); Assert.Contains("IedBrowserReadValuePolicy.TryAcceptDisplay", shared); - Assert.Contains("IedBrowserReadValuePolicy.TryAcceptDisplay", ui); + Assert.Contains("IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString", ui); + Assert.Contains("result.VerifiedStringWireKind", ui); + Assert.Contains("declaredDataType: result.Target.DataType", ui); Assert.Contains("failureExamples", ui); } From 98df880ca62d23d7a7b6ae8c382b744c9a6dc4e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:58:17 +0700 Subject: [PATCH 11/23] perf(model): reuse typed parent ST/SE sibling proofs and publish unscheduled siblings once --- Services/IedBrowserTypedDoReadFlight.cs | 31 ++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/Services/IedBrowserTypedDoReadFlight.cs b/Services/IedBrowserTypedDoReadFlight.cs index cf1ec3407..6614b632b 100644 --- a/Services/IedBrowserTypedDoReadFlight.cs +++ b/Services/IedBrowserTypedDoReadFlight.cs @@ -12,15 +12,20 @@ internal sealed class IedBrowserTypedDoReadFlight private const int MaximumParentAttempts = 24; private readonly NativeIec61850Client _client; private readonly Dictionary> _verified; + private readonly HashSet _scheduledKeys; + private readonly HashSet _drainedParents = new(StringComparer.Ordinal); private readonly Dictionary> _completed = new(StringComparer.Ordinal); private int _attemptCount; public IedBrowserTypedDoReadFlight( NativeIec61850Client client, - IReadOnlyList verifiedScope) + IReadOnlyList verifiedScope, + IReadOnlyList? scheduledTargets = null) { _client = client; + _scheduledKeys = (scheduledTargets ?? verifiedScope) + .Select(Key).ToHashSet(StringComparer.Ordinal); _verified = verifiedScope .GroupBy(IedBrowserVerifiedDoProjection.ParentFor, StringComparer.Ordinal) .Where(g => g.Key.Length > 0 && g.Count() <= 128) @@ -34,6 +39,30 @@ private static string Key(IedBrowserReadTarget t) => IedBrowserLiveValues.CanonicalReference(t.Reference) + "|" + t.FunctionalConstraint.Trim().ToUpperInvariant(); + // Reuse already GVA-verified siblings in this one Read operation. A + // successful parent DO read must not be followed by N redundant wire + // leaf reads that can fail independently and spoil coverage counts. + internal IedBrowserReadResult? PreviouslyVerified(IedBrowserReadTarget target) + { + var parent = IedBrowserVerifiedDoProjection.ParentFor(target); + return parent.Length > 0 && _completed.TryGetValue(parent, out var values) && + values.TryGetValue(Key(target), out var verified) ? verified : null; + } + + // A selected DO read may prove siblings that were not scheduled in this + // particular page; publish them at most once through the SAME evidence + // inbox. No speculative values: only present verified parent results. + internal IReadOnlyList DrainUnscheduledSiblings( + IedBrowserReadTarget selected) + { + var parent = IedBrowserVerifiedDoProjection.ParentFor(selected); + if (parent.Length == 0 || !_completed.TryGetValue(parent, out var verified) || + !_drainedParents.Add(parent)) + return []; + return verified.Where(item => !_scheduledKeys.Contains(item.Key)) + .Select(item => item.Value).ToArray(); + } + public async Task TryReadAsync( IedBrowserReadTarget failedLeaf, CancellationToken cancellationToken) { From 9b81c9b3d6e639f06b673be9aeca04382ecab49f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:58:35 +0700 Subject: [PATCH 12/23] perf(model): consume exact verified parent sibling proofs before redundant MMS leaf reads --- Services/IedBrowserIsolatedReadService.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Services/IedBrowserIsolatedReadService.cs b/Services/IedBrowserIsolatedReadService.cs index 7c301569d..1747699cd 100644 --- a/Services/IedBrowserIsolatedReadService.cs +++ b/Services/IedBrowserIsolatedReadService.cs @@ -47,7 +47,7 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) // is genuinely unresolved; positive/negative parent probes are // memoized once per exact DO+FC, never per every child. var doReadFlight = new IedBrowserTypedDoReadFlight( - client, verifiedRootScope ?? targets); + client, verifiedRootScope ?? targets, targets); for (var i = 0; i < targets.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); @@ -57,6 +57,14 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) var target = targets[i]; try { + var previouslyVerified = doReadFlight.PreviouslyVerified(target); + if (previouslyVerified is not null) + { + values.Report(previouslyVerified); + successful++; + progress.Report(new IedBrowserReadProgress(i + 1, targets.Count)); + continue; + } using var leafToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // The root attempt has its OWN bounded per-root timeout // and negative-result cache. Do not pass a short leaf @@ -108,6 +116,11 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) { values.Report(verifiedParent); successful++; + foreach (var extra in doReadFlight.DrainUnscheduledSiblings(target)) + { + values.Report(extra); + successful++; + } } else values.Report(new IedBrowserReadResult(target, "", "", "", From 83787c029b59758ccd8a28f5087c0c7443d5de1b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:59:39 +0700 Subject: [PATCH 13/23] fix(model): honor GVA-verified primitive strings and typed bool in FC roots --- Models/IedBrowserSgRootProjection.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Models/IedBrowserSgRootProjection.cs b/Models/IedBrowserSgRootProjection.cs index bae8ab041..3f306e40b 100644 --- a/Models/IedBrowserSgRootProjection.cs +++ b/Models/IedBrowserSgRootProjection.cs @@ -96,11 +96,20 @@ void Walk(MmsTypeSpecificationNode schema, MmsDataValue value, string path, int var key = canonical + "|" + constraint; if (!paths.Add(key) || !targetIndex.TryGetValue(key, out var target)) return; - var display = MmsDataValueRenderer.ToCompactString(value); - if (!IedBrowserReadValuePolicy.TryAcceptDisplay( - display, out var accepted, out _)) + // An exact ordered GVA component proves this primitive's kind, + // not the meaning of a rendered "[" or "{" character. Preserve + // legitimate strings without admitting unverified structures. + var stringKind = value.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString + ? value.Kind : (MmsDataKind?)null; + var display = stringKind is not null && value.Value is string direct + ? direct : MmsDataValueRenderer.ToCompactString(value); + if (!IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + display, target.DataType, stringKind, out var accepted, out _)) return; - output.Add(new IedBrowserReadResult(target, accepted, "", "", "")); + output.Add(new IedBrowserReadResult(target, accepted, "", "", "", + TypedBoolean: value.Kind == MmsDataKind.Boolean && value.Value is bool typed + ? typed : null, + VerifiedStringWireKind: stringKind)); } Walk(wireType, wireData, "", 0); return output; From fc01e93f008622bcdf8fde2ac33fdb6f4788674d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 07:59:43 +0700 Subject: [PATCH 14/23] fix(model): honor GVA-verified primitive strings and typed bool in ST/SE DO parents --- Models/IedBrowserVerifiedDoProjection.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Models/IedBrowserVerifiedDoProjection.cs b/Models/IedBrowserVerifiedDoProjection.cs index 8893c8883..9d9ff1d95 100644 --- a/Models/IedBrowserVerifiedDoProjection.cs +++ b/Models/IedBrowserVerifiedDoProjection.cs @@ -118,10 +118,16 @@ void Walk(MmsTypeSpecificationNode schema, MmsDataValue wire, if (path.Length == 0) return; if (!targets.TryGetValue(prefix + "." + path + "|" + fc, out var target)) return; - var rendered = MmsDataValueRenderer.ToCompactString(wire); - if (IedBrowserReadValuePolicy.TryAcceptDisplay( - rendered, out var accepted, out _)) - found.Add(new IedBrowserReadResult(target, accepted, "", "", "")); + var stringKind = wire.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString + ? wire.Kind : (MmsDataKind?)null; + var rendered = stringKind is not null && wire.Value is string direct + ? direct : MmsDataValueRenderer.ToCompactString(wire); + if (IedBrowserReadValuePolicy.TryAcceptVerifiedMmsString( + rendered, target.DataType, stringKind, out var accepted, out _)) + found.Add(new IedBrowserReadResult(target, accepted, "", "", "", + TypedBoolean: wire.Kind == MmsDataKind.Boolean && wire.Value is bool typed + ? typed : null, + VerifiedStringWireKind: stringKind)); } Walk(type, data, "", 0); return found; From 807c10f7aed0932b9ac5a66edb92a0298f2a6faf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:00:35 +0700 Subject: [PATCH 15/23] test(model): exact GVA root/DO bracket string parity, typed false and single-flight sibling reuse --- .../ARSAS.Tests/P0ModelTypedMmsStringTests.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs index a411873ea..a185a2af4 100644 --- a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs +++ b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs @@ -67,6 +67,109 @@ public void LiveLedgerRetainsTypedProofOnlyForCurrentReadAssociation() Assert.False(ledger.TryGet(reference, "DC", out _, preferReport: false)); } + + [Fact] + public void ExactGvaDcRootCanProjectVendorTextStartingWithBracket() + { + const string root = "ZIVAVR/LLN0$DC0"; + var target = new IedBrowserReadTarget( + "ZIVAVR/LLN0$DC$NamPlt$swRev", "DC", "VisString255"); + var gva = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = + [ + new MmsTypeSpecificationNode + { + Name = "NamPlt", MmsType = "structure", + Children = [new MmsTypeSpecificationNode + { + Name = "swRev", MmsType = "visible-string" + }] + } + ] + }; + var wire = MmsDataValue.Structure([ + MmsDataValue.Structure([MmsDataValue.VisibleString("[IRV:firmware")]) + ]); + var result = Assert.Single(IedBrowserSgRootProjection.Project( + root, gva, wire, [target])); + Assert.Equal(target, result.Target); + Assert.Equal("[IRV:firmware", result.Value); + Assert.Equal(MmsDataKind.VisibleString, result.VerifiedStringWireKind); + Assert.False(IedBrowserSgRootProjection.Project(root, gva, wire, + [target with { FunctionalConstraint = "CF" }]).Any()); + } + + [Fact] + public void ExactGvaDoParentCanProjectLiteralStringButRejectWrongModelType() + { + const string parent = "ZIVAVR/CSWI1$SE$VendorDO"; + var target = new IedBrowserReadTarget( + "ZIVAVR/CSWI1$SE$VendorDO$setVal", "SE", "VisString64"); + var gva = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = [new MmsTypeSpecificationNode + { + Name = "setVal", MmsType = "visible-string" + }] + }; + var wire = MmsDataValue.Structure([MmsDataValue.VisibleString("{safe literal")]); + var result = Assert.Single(IedBrowserVerifiedDoProjection.Project( + parent, gva, wire, [target])); + Assert.Equal("{safe literal", result.Value); + Assert.Equal(MmsDataKind.VisibleString, result.VerifiedStringWireKind); + Assert.Empty(IedBrowserVerifiedDoProjection.Project( + parent, gva, wire, [target with { DataType = "INT32" }])); + } + + [Fact] + public void ExactGvaParentPreservesFalseTypedBooleanWithoutInventingTrue() + { + const string parent = "SIEBCUCTRL/CSWI1$ST$SwAuth"; + var target = new IedBrowserReadTarget( + "SIEBCUCTRL/CSWI1$ST$SwAuth$stVal", "ST", "Boolean"); + var gva = new MmsTypeSpecificationNode + { + MmsType = "structure", + Children = [new MmsTypeSpecificationNode + { + Name = "stVal", MmsType = "boolean" + }] + }; + var wire = MmsDataValue.Structure([MmsDataValue.Boolean(false)]); + var result = Assert.Single(IedBrowserVerifiedDoProjection.Project( + parent, gva, wire, [target])); + Assert.False(result.TypedBoolean); + Assert.Null(result.VerifiedStringWireKind); + } + + [Fact] + public void StSeParentFlightReusesOnlyVerifiedScheduledSiblingsAndBoundedUnscheduledFanout() + { + var service = ReadRepoSource("Services/IedBrowserIsolatedReadService.cs"); + var flight = ReadRepoSource("Services/IedBrowserTypedDoReadFlight.cs"); + Assert.Contains("doReadFlight.PreviouslyVerified(target)", service); + Assert.Contains("doReadFlight.DrainUnscheduledSiblings(target)", service); + Assert.Contains("_scheduledKeys.Contains(item.Key)", flight); + Assert.Contains("_drainedParents.Add(parent)", flight); + Assert.Contains("_completed.TryGetValue(parent", flight); + Assert.Contains("MaximumParentAttempts = 24", flight); + } + + private static string ReadRepoSource(string relativePath) + { + for (DirectoryInfo? root = new(AppContext.BaseDirectory); + root is not null; root = root.Parent) + { + var path = Path.Combine(root.FullName, + relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(path)) return File.ReadAllText(path); + } + throw new FileNotFoundException(relativePath); + } + [Fact] public void ReadDecodeProofMustFlowThroughIsolatedReaderAndBrowserIngestion() { From e11a19fa70638eb4fbd3121678b7970cb34a11e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:01:46 +0700 Subject: [PATCH 16/23] feat(model): bounded decoded MMS wire-shape evidence for scalar failure diagnosis --- Models/IedBrowserReadWireShapeEvidence.cs | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Models/IedBrowserReadWireShapeEvidence.cs diff --git a/Models/IedBrowserReadWireShapeEvidence.cs b/Models/IedBrowserReadWireShapeEvidence.cs new file mode 100644 index 000000000..5929d0a70 --- /dev/null +++ b/Models/IedBrowserReadWireShapeEvidence.cs @@ -0,0 +1,31 @@ +using AR.Iec61850.Mms; + +namespace ArIED61850Tester.Models; + +/// +/// Bounded failure-diagnostic metadata from one decoded MMS result. Never +/// copies raw BER, object payload or vendor strings into the evidence CSV. +/// Wire kind is NOT invoke-ID, GVA status or proof of successful scalar read. +/// +public static class IedBrowserReadWireShapeEvidence +{ + public static string Describe(MmsDataValue? decoded) + { + if (decoded is null) return "wire=null"; + var type = decoded.Value?.GetType().Name ?? "null"; + var kind = decoded.Kind.ToString(); + var scalarOrTree = decoded.Kind is MmsDataKind.Structure or MmsDataKind.Array + ? "composite" : "primitive"; + return "wireKind=" + kind + + ", category=" + scalarOrTree + + ", clrKind=" + type + + ", rawOctets=" + decoded.RawValue.Count.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + ", childCount=" + decoded.Children.Count.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + (decoded.Kind == MmsDataKind.Unknown + ? ", unknownTag=" + decoded.UnknownTagNumber.ToString( + System.Globalization.CultureInfo.InvariantCulture) + : ""); + } +} From a97cd0b280fa24ce1180480200a0a194b0ef9bcd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:02:15 +0700 Subject: [PATCH 17/23] fix(model): keep unknown BER tag summary compatible with nullable tags --- Models/IedBrowserReadWireShapeEvidence.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Models/IedBrowserReadWireShapeEvidence.cs b/Models/IedBrowserReadWireShapeEvidence.cs index 5929d0a70..71d1ae424 100644 --- a/Models/IedBrowserReadWireShapeEvidence.cs +++ b/Models/IedBrowserReadWireShapeEvidence.cs @@ -24,8 +24,7 @@ public static string Describe(MmsDataValue? decoded) ", childCount=" + decoded.Children.Count.ToString( System.Globalization.CultureInfo.InvariantCulture) + (decoded.Kind == MmsDataKind.Unknown - ? ", unknownTag=" + decoded.UnknownTagNumber.ToString( - System.Globalization.CultureInfo.InvariantCulture) + ? ", unknownTag=" + decoded.UnknownTagNumber.ToString() : ""); } } From 8edff42d391807af9ce78e9b0f906bba144e1c71 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:02:18 +0700 Subject: [PATCH 18/23] feat(model): preserve exact decoded wire-shape diagnostic with Read result --- Services/Iec61850ReadValue.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Services/Iec61850ReadValue.cs b/Services/Iec61850ReadValue.cs index a100b669e..fca3112bf 100644 --- a/Services/Iec61850ReadValue.cs +++ b/Services/Iec61850ReadValue.cs @@ -19,6 +19,10 @@ public sealed class Iec61850ReadValue // receive this marker. A parent structure can never claim this proof. public MmsDataKind? VerifiedStringWireKind { get; internal set; } + // Diagnostic only: decoded kind/counts without BER or scalar payload. + // Does not claim GVA, invokeID capture or remote interoperability. + public string WireShapeEvidence { get; internal set; } = string.Empty; + // Authoritative engine-owned UTC-Time evidence. Available only when // a direct MMS .t read preserved the original eight wire bytes. public Iec61850UtcTimeEvidence? UtcTimeEvidence { get; internal set; } From f2df9814fc50f4a2d0036963b7b6de822f504d7c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:02:24 +0700 Subject: [PATCH 19/23] feat(model): attach bounded wire kind and decoded-shape metadata at native read --- Services/NativeIec61850Client.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 12e4cd444..34a2cae0c 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1129,6 +1129,7 @@ private static string BuildDynamicDataSetName(ReportControlPlan plan) Iec61850ReadValue? projected, ArMms.MmsDataValue? raw, string reference) { if (projected is null || raw is null) return projected; + projected.WireShapeEvidence = IedBrowserReadWireShapeEvidence.Describe(raw); if (raw.Kind is ArMms.MmsDataKind.VisibleString or ArMms.MmsDataKind.MmsString && raw.Value is string decoded && projected.Value is string scalar && From 91337e776ed38fb3f3212fb028aa385c8a1000c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:02:27 +0700 Subject: [PATCH 20/23] feat(model): export decoded MMS kind/shape on unresolved scalar without raw payload --- Models/IedBrowserRejectedScalarEvidence.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Models/IedBrowserRejectedScalarEvidence.cs b/Models/IedBrowserRejectedScalarEvidence.cs index 21841015d..49af6aa9b 100644 --- a/Models/IedBrowserRejectedScalarEvidence.cs +++ b/Models/IedBrowserRejectedScalarEvidence.cs @@ -35,6 +35,8 @@ static string Safe(string? value) ", modelType=" + Safe(modelType) + ", projection=" + Safe(read.Projection) + ", CLR=" + payloadType + + ", wire=" + Safe(string.IsNullOrWhiteSpace(read.WireShapeEvidence) + ? "not captured" : read.WireShapeEvidence) + ", display='" + Safe(read.DisplayValue) + "'" + ", source=" + Safe(read.ReadReference) + "; GVA/invoke raw not captured)."; From 82eed1425ce49a69cfe82ad2d316f0fbd2e8d344 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:02:48 +0700 Subject: [PATCH 21/23] test(model): prove bounded wire-shape evidence never exports scalar payload --- .../ARSAS.Tests/P0ModelTypedMmsStringTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs index a185a2af4..fe04cb53b 100644 --- a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs +++ b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs @@ -158,6 +158,34 @@ public void StSeParentFlightReusesOnlyVerifiedScheduledSiblingsAndBoundedUnsched Assert.Contains("MaximumParentAttempts = 24", flight); } + [Fact] + public void UnresolvedWireShapeExplainsDecoderKindWithoutLoggingVendorValues() + { + var primitive = IedBrowserReadWireShapeEvidence.Describe( + MmsDataValue.Integer(-517832)); + Assert.Contains("wireKind=Integer", primitive); + Assert.Contains("category=primitive", primitive); + Assert.Contains("childCount=0", primitive); + Assert.DoesNotContain("-517832", primitive); + + var composite = IedBrowserReadWireShapeEvidence.Describe( + MmsDataValue.Structure([ + MmsDataValue.Integer(468123), + MmsDataValue.Boolean(false) + ])); + Assert.Contains("wireKind=Structure", composite); + Assert.Contains("category=composite", composite); + Assert.Contains("childCount=2", composite); + Assert.DoesNotContain("468123", composite); + Assert.Equal("wire=null", IedBrowserReadWireShapeEvidence.Describe(null)); + + var native = ReadRepoSource("Services/NativeIec61850Client.cs"); + var failure = ReadRepoSource("Models/IedBrowserRejectedScalarEvidence.cs"); + Assert.Contains("WireShapeEvidence = IedBrowserReadWireShapeEvidence.Describe(raw)", native); + Assert.Contains("read.WireShapeEvidence", failure); + Assert.DoesNotContain("LastReadResponseHex", failure); + } + private static string ReadRepoSource(string relativePath) { for (DirectoryInfo? root = new(AppContext.BaseDirectory); From b6e2e68c857433ee66a65cf120fb5f15c54f85e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:04:16 +0700 Subject: [PATCH 22/23] fix(model): deduplicate exact ref+FC across root and DO fanout; count verified leaves once --- Services/IedBrowserIsolatedReadService.cs | 36 +++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/Services/IedBrowserIsolatedReadService.cs b/Services/IedBrowserIsolatedReadService.cs index 1747699cd..5865d7553 100644 --- a/Services/IedBrowserIsolatedReadService.cs +++ b/Services/IedBrowserIsolatedReadService.cs @@ -41,6 +41,19 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) } var successful = 0; + // One exact key per observed child across typed FC roots and + // same-DO parent fallbacks. An overlapping fanout must never + // publish duplicate ledger revisions or inflate Read All success. + var emitted = new HashSet(StringComparer.Ordinal); + static string Key(IedBrowserReadTarget target) => + IedBrowserLiveValues.CanonicalReference(target.Reference) + "|" + + target.FunctionalConstraint.Trim().ToUpperInvariant(); + void EmitVerified(IedBrowserReadResult result) + { + if (!emitted.Add(Key(result.Target))) return; + values.Report(result); + successful++; + } var sgRootFlight = new IedBrowserSgRootReadFlight( client, targets, verifiedRootScope); // One association-scoped flight, only used AFTER a scalar Read @@ -55,13 +68,17 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) throw new InvalidOperationException( "Live IED discovery/model or monitoring association changed during isolated MMS Read."); var target = targets[i]; + if (emitted.Contains(Key(target))) + { + progress.Report(new IedBrowserReadProgress(i + 1, targets.Count)); + continue; + } try { var previouslyVerified = doReadFlight.PreviouslyVerified(target); if (previouslyVerified is not null) { - values.Report(previouslyVerified); - successful++; + EmitVerified(previouslyVerified); progress.Report(new IedBrowserReadProgress(i + 1, targets.Count)); continue; } @@ -77,13 +94,11 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) // the queued target itself was absent or refused. foreach (var extra in sgRootFlight.DrainUnscheduledSiblings(target)) { - values.Report(extra); - successful++; + EmitVerified(extra); } if (verifiedSg is not null) { - values.Report(verifiedSg); - successful++; + EmitVerified(verifiedSg); progress.Report(new IedBrowserReadProgress(i + 1, targets.Count)); continue; } @@ -97,12 +112,11 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) display, target.DataType, read?.VerifiedStringWireKind, out var accepted, out var reason)) { - values.Report(new IedBrowserReadResult(target, accepted, + EmitVerified(new IedBrowserReadResult(target, accepted, read?.Quality ?? "", read?.DeviceTimestamp ?? "", "", read?.UtcTimeEvidence, read?.QualityEvidence, read?.Value is bool typed ? typed : null, read?.VerifiedStringWireKind)); - successful++; } else { @@ -114,12 +128,10 @@ await client.ConnectAsync(device.IpAddress, device.Port, cancellationToken) target, cancellationToken).ConfigureAwait(false); if (verifiedParent is not null) { - values.Report(verifiedParent); - successful++; + EmitVerified(verifiedParent); foreach (var extra in doReadFlight.DrainUnscheduledSiblings(target)) { - values.Report(extra); - successful++; + EmitVerified(extra); } } else From 0f666903b2987eaefdefc5089e7ef85736ff5a12 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 08:04:48 +0700 Subject: [PATCH 23/23] test(model): guard one-count exact FC ledger admission across root and DO fanouts --- tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs index fe04cb53b..b82fcef0e 100644 --- a/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs +++ b/tests/ARSAS.Tests/P0ModelTypedMmsStringTests.cs @@ -158,6 +158,21 @@ public void StSeParentFlightReusesOnlyVerifiedScheduledSiblingsAndBoundedUnsched Assert.Contains("MaximumParentAttempts = 24", flight); } + [Fact] + public void ReadAllDeduplicatesVerifiedRefAndFcAcrossIndependentParentFlights() + { + var isolated = ReadRepoSource("Services/IedBrowserIsolatedReadService.cs"); + Assert.Contains("var emitted = new HashSet(StringComparer.Ordinal)", isolated); + Assert.Contains("IedBrowserLiveValues.CanonicalReference(target.Reference)", isolated); + Assert.Contains("target.FunctionalConstraint.Trim().ToUpperInvariant()", isolated); + Assert.Contains("if (!emitted.Add(Key(result.Target))) return;", isolated); + Assert.Contains("if (emitted.Contains(Key(target)))", isolated); + Assert.Contains("EmitVerified(verifiedSg)", isolated); + Assert.Contains("EmitVerified(verifiedParent)", isolated); + Assert.Contains("EmitVerified(previouslyVerified)", isolated); + Assert.Equal(1, isolated.Split("successful++;").Length - 1); + } + [Fact] public void UnresolvedWireShapeExplainsDecoderKindWithoutLoggingVendorValues() {