Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6a0cbaf
fix(model): allow bracket-leading strings only with verified MMS kind…
masarray Oct 22, 2026
ab2a8a2
feat(model): preserve primitive MMS string-kind proof with projected …
masarray Oct 22, 2026
c5d094c
feat(model): attach exact primitive MMS string proof during decoding
masarray Oct 22, 2026
8d074df
feat(model): carry typed string MMS proof through association-fenced …
masarray Oct 22, 2026
01bddc3
fix(model): accept verified bracket-leading MMS string scalar in isol…
masarray Oct 22, 2026
cffc670
fix(model): carry verified decoded string evidence through Browser UI…
masarray Oct 22, 2026
e8f69e1
test(model): bracket-leading vendor string and unsafe MMS projection …
masarray Oct 22, 2026
91035e6
fix(model): forbid parent-DO scalar from masquerading as exact string…
masarray Oct 22, 2026
3a5a3f5
test(model): ensure verified primitive text is bound to exact leaf no…
masarray Oct 22, 2026
4021c74
test(model): update legacy verdict contract to require wire-proven st…
masarray Oct 22, 2026
98df880
perf(model): reuse typed parent ST/SE sibling proofs and publish unsc…
masarray Oct 22, 2026
9b81c9b
perf(model): consume exact verified parent sibling proofs before redu…
masarray Oct 22, 2026
83787c0
fix(model): honor GVA-verified primitive strings and typed bool in FC…
masarray Oct 22, 2026
fc01e93
fix(model): honor GVA-verified primitive strings and typed bool in ST…
masarray Oct 22, 2026
807c10f
test(model): exact GVA root/DO bracket string parity, typed false and…
masarray Oct 22, 2026
e11a19f
feat(model): bounded decoded MMS wire-shape evidence for scalar failu…
masarray Oct 22, 2026
a97cd0b
fix(model): keep unknown BER tag summary compatible with nullable tags
masarray Oct 22, 2026
8edff42
feat(model): preserve exact decoded wire-shape diagnostic with Read r…
masarray Oct 22, 2026
f2df981
feat(model): attach bounded wire kind and decoded-shape metadata at n…
masarray Oct 22, 2026
91337e7
feat(model): export decoded MMS kind/shape on unresolved scalar witho…
masarray Oct 22, 2026
82eed14
test(model): prove bounded wire-shape evidence never exports scalar p…
masarray Oct 22, 2026
b6e2e68
fix(model): deduplicate exact ref+FC across root and DO fanout; count…
masarray Oct 22, 2026
0f66690
test(model): guard one-count exact FC ledger admission across root an…
masarray Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand All @@ -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++;
Expand Down
18 changes: 13 additions & 5 deletions Models/IedBrowserLiveValues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Atomically attach a verified, exact-model-owned q or t Report companion
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
34 changes: 34 additions & 0 deletions Models/IedBrowserReadValuePolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,40 @@ public static bool TryFormatVerifiedEmptyVisible(
return false;
}

/// <summary>
/// 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.
/// </summary>
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)
{
Expand Down
30 changes: 30 additions & 0 deletions Models/IedBrowserReadWireShapeEvidence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using AR.Iec61850.Mms;

namespace ArIED61850Tester.Models;

/// <summary>
/// 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.
/// </summary>
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()
: "");
}
}
2 changes: 2 additions & 0 deletions Models/IedBrowserRejectedScalarEvidence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).";
Expand Down
17 changes: 13 additions & 4 deletions Models/IedBrowserSgRootProjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 10 additions & 4 deletions Models/IedBrowserVerifiedDoProjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions Services/Iec61850ReadValue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ 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; }

// 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; }
Expand Down
51 changes: 39 additions & 12 deletions Services/IedBrowserIsolatedReadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,47 @@ 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<string>(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
// 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();
if (lease is not null && !lease.IsCurrent(device))
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)
{
EmitVerified(previouslyVerified);
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
Expand All @@ -69,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;
}
Expand All @@ -85,14 +108,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,
EmitVerified(new IedBrowserReadResult(target, accepted,
read?.Quality ?? "", read?.DeviceTimestamp ?? "", "",
read?.UtcTimeEvidence, read?.QualityEvidence,
read?.Value is bool typed ? typed : null));
successful++;
read?.Value is bool typed ? typed : null,
read?.VerifiedStringWireKind));
}
else
{
Expand All @@ -104,8 +128,11 @@ 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))
{
EmitVerified(extra);
}
}
else
values.Report(new IedBrowserReadResult(target, "", "", "",
Expand Down
31 changes: 30 additions & 1 deletion Services/IedBrowserTypedDoReadFlight.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,20 @@ internal sealed class IedBrowserTypedDoReadFlight
private const int MaximumParentAttempts = 24;
private readonly NativeIec61850Client _client;
private readonly Dictionary<string, IReadOnlyList<IedBrowserReadTarget>> _verified;
private readonly HashSet<string> _scheduledKeys;
private readonly HashSet<string> _drainedParents = new(StringComparer.Ordinal);
private readonly Dictionary<string, IReadOnlyDictionary<string, IedBrowserReadResult>>
_completed = new(StringComparer.Ordinal);
private int _attemptCount;

public IedBrowserTypedDoReadFlight(
NativeIec61850Client client,
IReadOnlyList<IedBrowserReadTarget> verifiedScope)
IReadOnlyList<IedBrowserReadTarget> verifiedScope,
IReadOnlyList<IedBrowserReadTarget>? 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)
Expand All @@ -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<IedBrowserReadResult> 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<IedBrowserReadResult?> TryReadAsync(
IedBrowserReadTarget failedLeaf, CancellationToken cancellationToken)
{
Expand Down
Loading
Loading