From ad7e65bec6204ea0cc4fe2bbd315bb217f63e993 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:23:41 +0700 Subject: [PATCH 01/12] fix(mms): reject malformed primitive wire payloads instead of fabricating false or zero --- src/AR.Iec61850/Mms/MmsDataCodec.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsDataCodec.cs b/src/AR.Iec61850/Mms/MmsDataCodec.cs index 086ed318..ff6ad0d5 100644 --- a/src/AR.Iec61850/Mms/MmsDataCodec.cs +++ b/src/AR.Iec61850/Mms/MmsDataCodec.cs @@ -39,11 +39,16 @@ public static MmsDataValue Decode(BerTlv tlv) { 1 => MmsDataValue.Array(DecodeAllData(tlv.Value)), 2 => MmsDataValue.Structure(DecodeAllData(tlv.Value)), - 3 => MmsDataValue.Boolean(BerReader.ReadBoolean(tlv) ?? false), + 3 => BerReader.ReadBoolean(tlv) is { } booleanValue + ? MmsDataValue.Boolean(booleanValue) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), 4 => DecodeBitString(tlv.Value.Span), - 5 => MmsDataValue.Integer(BerReader.ReadSignedInteger(tlv) ?? 0), - 6 => MmsDataValue.Unsigned(BerReader.ReadUnsignedInteger(tlv) ?? 0), - 7 => MmsDataValue.FloatingPoint(DecodeFloatingPoint(tlv.Value.Span)), + 5 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadSignedInteger(tlv) is { } signed + ? MmsDataValue.Integer(signed) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), + 6 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadUnsignedInteger(tlv) is { } unsigned + ? MmsDataValue.Unsigned(unsigned) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), + 7 => tlv.Value.Length is 4 or 5 + ? MmsDataValue.FloatingPoint(DecodeFloatingPoint(tlv.Value.Span)) + : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), 9 => MmsDataValue.OctetString(tlv.Value.Span), 10 => MmsDataValue.VisibleString(BerReader.ReadAsciiString(tlv)), 12 => MmsDataValue.BinaryTime(tlv.Value.Span), @@ -126,8 +131,8 @@ private static byte TagFor(MmsDataKind kind, int? unknownTagNumber) private static MmsDataValue DecodeBitString(ReadOnlySpan bytes) { - if (bytes.IsEmpty) - return MmsDataValue.BitString(0, ReadOnlySpan.Empty); + if (bytes.IsEmpty || bytes[0] > 7 || (bytes.Length == 1 && bytes[0] != 0)) + return MmsDataValue.Unknown(4, bytes); return MmsDataValue.BitString(bytes[0], bytes[1..]); } From b1682e8e2e347c3b62db52d0fade5ce1a977d899 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:23:54 +0700 Subject: [PATCH 02/12] fix(mms): enforce BER boolean payload width before typed admission --- src/AR.Iec61850/Mms/MmsDataCodec.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsDataCodec.cs b/src/AR.Iec61850/Mms/MmsDataCodec.cs index ff6ad0d5..3150b312 100644 --- a/src/AR.Iec61850/Mms/MmsDataCodec.cs +++ b/src/AR.Iec61850/Mms/MmsDataCodec.cs @@ -39,7 +39,7 @@ public static MmsDataValue Decode(BerTlv tlv) { 1 => MmsDataValue.Array(DecodeAllData(tlv.Value)), 2 => MmsDataValue.Structure(DecodeAllData(tlv.Value)), - 3 => BerReader.ReadBoolean(tlv) is { } booleanValue + 3 => tlv.Value.Length == 1 && BerReader.ReadBoolean(tlv) is { } booleanValue ? MmsDataValue.Boolean(booleanValue) : MmsDataValue.Unknown(tlv.TagNumber, tlv.Value.Span), 4 => DecodeBitString(tlv.Value.Span), 5 => tlv.Value.Length is > 0 and <= 8 && BerReader.ReadSignedInteger(tlv) is { } signed From 5dbccbce3f4b60f8fed6a57a82696544f2e26862 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:24:19 +0700 Subject: [PATCH 03/12] test(mms): malformed boolean/int/float/bitstring cannot become synthetic zero or false --- .../Mms/P0CTypedMmsPrimitiveEvidenceTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs b/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs new file mode 100644 index 00000000..81892a86 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/P0CTypedMmsPrimitiveEvidenceTests.cs @@ -0,0 +1,51 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +/// Independent synthetic BER fixtures; no device-specific captured bytes. +public sealed class P0CTypedMmsPrimitiveEvidenceTests +{ + [Theory] + [InlineData("8300", 3)] + [InlineData("83020001", 3)] + [InlineData("8500", 5)] + [InlineData("8600", 6)] + [InlineData("8509000000000000000000", 5)] + [InlineData("8609000000000000000000", 6)] + [InlineData("8703000000", 7)] + [InlineData("8400", 4)] + [InlineData("840108", 4)] + public void Malformed_Primitive_Is_Unknown_Not_False_Or_Zero(string berHex, int tag) + { + var values = MmsDataCodec.DecodeAllData(Convert.FromHexString(berHex)); + var decoded = Assert.Single(values); + Assert.Equal(MmsDataKind.Unknown, decoded.Kind); + Assert.Equal(tag, decoded.UnknownTagNumber); + } + + [Fact] + public void Valid_Zero_And_False_Are_Real_Observed_Typed_Values() + { + var encoded = MmsDataCodec.EncodeAllData([ + MmsDataValue.Boolean(false), MmsDataValue.Integer(0), + MmsDataValue.Unsigned(0), MmsDataValue.Integer(-3), + MmsDataValue.Unsigned(4294967295)]); + var decoded = MmsDataCodec.DecodeAllData(encoded); + Assert.Equal(false, decoded[0].Value); + Assert.Equal(0L, decoded[1].Value); + Assert.Equal(0UL, decoded[2].Value); + Assert.Equal(-3L, decoded[3].Value); + Assert.Equal(4294967295UL, decoded[4].Value); + Assert.All(decoded, x => Assert.NotEqual(MmsDataKind.Unknown, x.Kind)); + } + + [Fact] + public void Raw_Unknown_Wire_Kind_Never_Produces_A_Projected_Value() + { + var unknown = Assert.Single(MmsDataCodec.DecodeAllData( + Convert.FromHexString("8500"))); + Assert.Equal(MmsDataKind.Unknown, unknown.Kind); + Assert.Equal("85", MmsDataCodec.Encode(unknown).Length > 0 ? + Convert.ToHexString(MmsDataCodec.Encode(unknown))[..2] : ""); + } +} From 91894b3e1b0bfbe0b0385c0ad4e1b2651435b77a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:24:57 +0700 Subject: [PATCH 04/12] feat(mms): typed access-failure taxonomy distinct from read result payload --- .../Mms/MmsReadEvidenceClassifier.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs diff --git a/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs b/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs new file mode 100644 index 00000000..05a53662 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsReadEvidenceClassifier.cs @@ -0,0 +1,32 @@ +namespace AR.Iec61850.Mms; + +/// +/// Evidence classification is orthogonal to MMS Data type: a failed access +/// never becomes a scalar, including when an expected SCL type exists. +/// +public enum MmsReadEvidenceFailureKind +{ + None, + AccessDenied, + NotSupported, + TypeMismatch, + ObjectMissing, + TemporarilyUnavailable, + RemoteAccessFailure, + InvalidResponse, + ProtocolRejected +} + +public static class MmsReadEvidenceClassifier +{ + public static MmsReadEvidenceFailureKind FromAccessCode(int? code) + => code switch + { + 2 => MmsReadEvidenceFailureKind.TemporarilyUnavailable, + 3 => MmsReadEvidenceFailureKind.AccessDenied, + 4 or 5 or 10 => MmsReadEvidenceFailureKind.ObjectMissing, + 6 or 9 => MmsReadEvidenceFailureKind.NotSupported, + 7 or 8 => MmsReadEvidenceFailureKind.TypeMismatch, + _ => MmsReadEvidenceFailureKind.RemoteAccessFailure + }; +} From 1c8e10503fec18934d780ce8c6258374d9ce325f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:25:36 +0700 Subject: [PATCH 05/12] feat(mms): propagate typed single read failure evidence without synthetic values --- src/AR.Iec61850/Mms/MmsRead.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsRead.cs b/src/AR.Iec61850/Mms/MmsRead.cs index 554f5179..fbf1cf4a 100644 --- a/src/AR.Iec61850/Mms/MmsRead.cs +++ b/src/AR.Iec61850/Mms/MmsRead.cs @@ -16,6 +16,7 @@ public sealed class MmsReadResult public bool IsSuccess { get; init; } public MmsDataValue? Value { get; init; } public int? FailureCode { get; init; } + public MmsReadEvidenceFailureKind FailureKind { get; init; } public string Message { get; init; } = string.Empty; public string ResponseHexPreview { get; init; } = string.Empty; } @@ -93,10 +94,10 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati return Fail("Empty MMS response payload.", hex); if (mms[0] == 0xA2) - return Fail($"MMS Confirmed-Error PDU received: {HexDump.ToCompactString(mms)}", hex); + return Fail($"MMS Confirmed-Error PDU received: {HexDump.ToCompactString(mms)}", hex, kind: MmsReadEvidenceFailureKind.ProtocolRejected); if (mms[0] == 0xA3 || mms[0] == 0xA4) - return Fail($"MMS Reject/Abort PDU received: {HexDump.ToCompactString(mms)}", hex); + return Fail($"MMS Reject/Abort PDU received: {HexDump.ToCompactString(mms)}", hex, kind: MmsReadEvidenceFailureKind.ProtocolRejected); if (!TryValidateConfirmedResponse(mms, expectedInvokeId, out var message)) return Fail(message, hex); @@ -113,7 +114,9 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati } if (!string.IsNullOrWhiteSpace(accessMessage)) - return Fail(accessMessage, hex, failureCode); + return Fail(accessMessage, hex, failureCode, failureCode.HasValue + ? MmsReadEvidenceClassifier.FromAccessCode(failureCode) + : MmsReadEvidenceFailureKind.InvalidResponse); // Legacy fallback for tolerant decoding of non-standard/short responses. var values = new List(); @@ -123,7 +126,8 @@ public static MmsReadResult DecodeSingleVariable(ReadOnlyMemory presentati if (failure.EncodedTag == 0x80) { var code = BerReader.ReadUnsignedInteger(failure); - return Fail(FormatDataAccessFailure(code), hex, code.HasValue ? (int)code.Value : null); + return Fail(FormatDataAccessFailure(code), hex, code.HasValue ? (int)code.Value : null, + MmsReadEvidenceClassifier.FromAccessCode(code.HasValue ? (int)code.Value : null)); } foreach (var tlv in values.AsEnumerable().Reverse()) @@ -365,12 +369,14 @@ private static string DataAccessErrorName(ulong code) _ => "unknown" }; - private static MmsReadResult Fail(string message, string hex, int? failureCode = null) + private static MmsReadResult Fail(string message, string hex, int? failureCode = null, + MmsReadEvidenceFailureKind kind = MmsReadEvidenceFailureKind.InvalidResponse) { return new MmsReadResult { IsSuccess = false, FailureCode = failureCode, + FailureKind = kind, Message = message, ResponseHexPreview = hex }; From e58f33ca3a4d6902c76da8f87d2ffafc1a393f1c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:25:40 +0700 Subject: [PATCH 06/12] feat(mms): propagate typed ordered batch read failure evidence without synthetic values --- src/AR.Iec61850/Mms/MmsReadBatch.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReadBatch.cs b/src/AR.Iec61850/Mms/MmsReadBatch.cs index ccfbc03e..d6e80869 100644 --- a/src/AR.Iec61850/Mms/MmsReadBatch.cs +++ b/src/AR.Iec61850/Mms/MmsReadBatch.cs @@ -9,6 +9,7 @@ public sealed class MmsReadAccessResult public bool IsSuccess { get; init; } public MmsDataValue? Value { get; init; } public int? FailureCode { get; init; } + public MmsReadEvidenceFailureKind FailureKind { get; init; } public string Message { get; init; } = string.Empty; } @@ -133,6 +134,7 @@ public static MmsReadBatchResult DecodeResponse( { Reference = references[index], IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = $"MMS Read response ended before access result {index}." }); continue; @@ -166,6 +168,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = false, FailureCode = code.HasValue ? (int)code.Value : null, + FailureKind = MmsReadEvidenceClassifier.FromAccessCode(code.HasValue ? (int)code.Value : null), Message = code.HasValue ? $"MMS Read AccessResult.failure code {code.Value}." : "MMS Read AccessResult.failure code is undecodable." @@ -180,6 +183,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = value.Kind != MmsDataKind.Unknown, Value = value.Kind == MmsDataKind.Unknown ? null : value, + FailureKind = value.Kind == MmsDataKind.Unknown ? MmsReadEvidenceFailureKind.InvalidResponse : MmsReadEvidenceFailureKind.None, Message = value.Kind == MmsDataKind.Unknown ? "MMS Data value is unknown." : "MMS Data value decoded." }; } @@ -195,6 +199,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen Reference = reference, IsSuccess = value.Kind != MmsDataKind.Unknown, Value = value.Kind == MmsDataKind.Unknown ? null : value, + FailureKind = value.Kind == MmsDataKind.Unknown ? MmsReadEvidenceFailureKind.InvalidResponse : MmsReadEvidenceFailureKind.None, Message = value.Kind == MmsDataKind.Unknown ? "Wrapped MMS Data value is unknown." : "Wrapped MMS Data value decoded." }; } @@ -204,6 +209,7 @@ private static MmsReadAccessResult DecodeAccessResult(MmsObjectReference referen { Reference = reference, IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = $"Unsupported MMS AccessResult tag 0x{accessResult.EncodedTag:X2}." }; } @@ -231,6 +237,7 @@ private static MmsReadBatchResult FailAll( { Reference = reference, IsSuccess = false, + FailureKind = MmsReadEvidenceFailureKind.InvalidResponse, Message = message }).ToArray(), Message = message, From 186b84414d5180d3c8f01212ea00f74f30e2c8d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:26:54 +0700 Subject: [PATCH 07/12] fix(mms): quarantine entire batch when access-result ordinal count mismatches --- src/AR.Iec61850/Mms/MmsReadBatch.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Mms/MmsReadBatch.cs b/src/AR.Iec61850/Mms/MmsReadBatch.cs index d6e80869..08862542 100644 --- a/src/AR.Iec61850/Mms/MmsReadBatch.cs +++ b/src/AR.Iec61850/Mms/MmsReadBatch.cs @@ -125,6 +125,14 @@ public static MmsReadBatchResult DecodeResponse( return FailAll(references, "MMS Read response has no listOfAccessResult.", hex); var accessResults = BerReader.ReadChildren(accessList.Value); + // A Read response with missing/extra access results cannot be + // safely joined to requested FC tuples by ordinal. Retain the + // mismatch as failure evidence instead of accepting a prefix. + if (accessResults.Count != references.Count) + return FailAll(references, + $"MMS Read AccessResult count mismatch: requested={references.Count}, received={accessResults.Count}.", + hex, Math.Max(0, accessResults.Count - references.Count)); + var results = new List(references.Count); for (var index = 0; index < references.Count; index++) { @@ -230,9 +238,11 @@ private static bool IsMmsData(BerTlv node) private static MmsReadBatchResult FailAll( IReadOnlyList references, string message, - string hex) + string hex, + int extraAccessResultCount = 0) => new() { + ExtraAccessResultCount = extraAccessResultCount, Results = references.Select(reference => new MmsReadAccessResult { Reference = reference, From 1414948c07d30e04b74ebd7a4bfb08b0fade6383 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:27:13 +0700 Subject: [PATCH 08/12] ci(p0-c): run full Windows engine gate on P0-B-stacked typed read branch --- .github/workflows/dotnet-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 967b0a7d..c20e5cc6 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -22,7 +22,7 @@ on: pull_request: # The live ARSAS product pins PR #169 until targeted engine changes pass CI. # Stacked engine PRs must run the exact same Windows build/test gate. - branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe ] + branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe, fix/p0-b-target-scoped-rcb-pager-20261019 ] workflow_dispatch: permissions: From ba21d882f67de22c10d3155b449aac77aa8b4899 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:27:51 +0700 Subject: [PATCH 09/12] test(p0-c): typed per-access MMS Read failure, invoke fence, strict ordinal and valid false/zero --- .../Mms/P0CTypedReadAccessResultTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs b/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs new file mode 100644 index 00000000..026be7b4 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/P0CTypedReadAccessResultTests.cs @@ -0,0 +1,86 @@ +using AR.Iec61850.Asn1; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class P0CTypedReadAccessResultTests +{ + [Fact] + public void Ordered_Confirmed_Read_Maintains_Int32_Zero_And_Individual_AccessFailures() + { + var references = Enumerable.Range(0, 4) + .Select(i => new MmsObjectReference("LD0", $"LLN0$SG$Value{i}$setVal", "SG")) + .ToArray(); + var response = Response(42, + MmsDataCodec.Encode(MmsDataValue.Integer(0)), + BerWriter.EncodeTlv(0x80, new byte[] { 3 }), + BerWriter.EncodeTlv(0x80, new byte[] { 6 }), + BerWriter.EncodeTlv(0x80, new byte[] { 7 })); + var batch = MmsReadBatchCodec.DecodeResponse(response, references, expectedInvokeId: 42); + Assert.Equal(4, batch.Results.Count); + Assert.Equal(0L, batch.Results[0].Value!.Value); + Assert.True(batch.Results[0].IsSuccess); + Assert.Equal(MmsReadEvidenceFailureKind.None, batch.Results[0].FailureKind); + Assert.Equal(MmsReadEvidenceFailureKind.AccessDenied, batch.Results[1].FailureKind); + Assert.Equal(3, batch.Results[1].FailureCode); + Assert.Equal(MmsReadEvidenceFailureKind.NotSupported, batch.Results[2].FailureKind); + Assert.Equal(MmsReadEvidenceFailureKind.TypeMismatch, batch.Results[3].FailureKind); + Assert.All(batch.Results.Skip(1), x => Assert.Null(x.Value)); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + public void Missing_Or_Extra_AccessResults_Fail_All_Without_Ordinal_Ambiguity(int resultCount) + { + var references = new[] + { + new MmsObjectReference("LD0", "LLN0$SG$PTOCEna$setVal", "SG"), + new MmsObjectReference("LD0", "LLN0$SG$InMagTyp$setVal", "SG") + }; + var response = Response(5, Enumerable.Range(0, resultCount) + .Select(_ => MmsDataCodec.Encode(MmsDataValue.Boolean(false))).ToArray()); + var result = MmsReadBatchCodec.DecodeResponse(response, references, expectedInvokeId: 5); + Assert.False(result.HasAnySuccess); + Assert.All(result.Results, x => + { + Assert.False(x.IsSuccess); + Assert.Null(x.Value); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, x.FailureKind); + }); + Assert.Contains("count mismatch", result.Message); + } + + [Fact] + public void Stale_Invoke_Cannot_Publish_Even_When_Data_Is_Decodable() + { + var refs = new[] {new MmsObjectReference("LD0", "LLN0$SG$PTOCEna$setVal", "SG")}; + var result = MmsReadBatchCodec.DecodeResponse( + Response(21, MmsDataCodec.Encode(MmsDataValue.Boolean(false))), refs, + expectedInvokeId: 22); + Assert.False(result.HasAnySuccess); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, + Assert.Single(result.Results).FailureKind); + } + + [Fact] + public void Invalid_Ber_Integer_Leaves_Unknown_Not_Observed_Zero() + { + var refs = new[] {new MmsObjectReference("LD0", "LLN0$SG$InMagTyp$setVal", "SG")}; + var result = MmsReadBatchCodec.DecodeResponse( + Response(42, Convert.FromHexString("8500")), refs, expectedInvokeId: 42); + var item = Assert.Single(result.Results); + Assert.False(item.IsSuccess); + Assert.Null(item.Value); + Assert.Equal(MmsReadEvidenceFailureKind.InvalidResponse, item.FailureKind); + } + + private static byte[] Response(int invokeId, params byte[][] accessResults) + { + var results = accessResults.SelectMany(item => item).ToArray(); + var service = BerWriter.EncodeTlv(0xA4, + BerWriter.EncodeTlv(0xA1, results)); + var invoke = BerWriter.EncodeTlv(0x02, BerWriter.EncodeUnsignedInteger((ulong)invokeId)); + return BerWriter.EncodeTlv(0xA1, invoke.Concat(service).ToArray()); + } +} From c85d4e20db722bd0c2729f2533b323b13d3ef735 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:35:19 +0700 Subject: [PATCH 10/12] fix: isolate RCB reports from control indications and expose control stage timing --- CHANGELOG.md | 6 +++ .../Control/Iec61850ControlObjectSession.cs | 31 +++++++++--- .../Mms/MmsPersistentReportMonitor.cs | 11 +++++ .../Control/G1OrderedWireEvidenceTests.cs | 8 ++++ .../Mms/MmsPersistentReportRoutingTests.cs | 47 +++++++++++++++++++ 5 files changed, 97 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a1089e..2895c155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Control/report isolation trial — 2026-10-20 + +- Reject non-RCB InformationReport shapes before persistent single-monitor fallback; control termination and LastApplError remain available to independent control subscribers. +- Include monotonic service duration and post-response wait in each control wire-step detail. UTC marks local service start, not wire send. This separates SBOw/Operate service latency from asynchronous completion wait without changing timeout, checks, or command sequencing. +- Synthetic regression coverage added; physical latency improvement is not yet established. + All notable public changes to ARIEC61850 are recorded here. The project is still evolving toward stable semantic releases; entries are grouped by engineering milestone until a formal versioning policy is adopted. ## Unreleased — public wording, provenance and release assurance diff --git a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs index 9550388e..1018c50b 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs @@ -27,6 +27,10 @@ private sealed class AssociationControlState private CancellationTokenSource? _selectionLeaseCts; private bool _ownsLock; private bool _disposed; + private DateTimeOffset _serviceStartedUtc; + private TimeSpan _serviceElapsed; + private long _serviceCompletedTimestamp; + internal Iec61850ControlObjectSession( IIec61850ControlTransport transport, @@ -115,7 +119,7 @@ private async Task SelectCoreAsync( try { var context = CreateContext(request); - var read = await _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken).ConfigureAwait(false); + var read = await MeasureServiceAsync(() => _transport.ReadAsync(Descriptor.References.Sbo, cancellationToken)).ConfigureAwait(false); if (!read.IsSuccess || !IsPositiveSboSelection(read.Value, Descriptor.References)) { ReleaseObject(); @@ -172,7 +176,7 @@ private async Task SelectWithValueCoreAsync( context, Descriptor.SelectWithValueSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.SboWithValue, value, cancellationToken)).ConfigureAwait(false); if (!write.IsSuccess) { var appError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false); @@ -289,7 +293,7 @@ private async Task OperateCoreAsync( context, Descriptor.OperSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Oper, operValue, linked.Token)).ConfigureAwait(false); if (!write.IsSuccess) { var applicationError = await WaitForApplicationErrorAsync(_options.ApplicationErrorGracePeriod, cancellationToken, reports).ConfigureAwait(false); @@ -432,7 +436,7 @@ private async Task CancelCoreAsync(CancellationToke return Unsupported(Iec61850ControlAction.Cancel, "IED did not expose a decodable Cancel type specification."); var value = Iec61850ControlStructureBuilder.BuildCancel(context, Descriptor.CancelSpecification, _options.RequireExactNamedControlFields); - var write = await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false); + var write = await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false); return write.IsSuccess ? Accepted(Iec61850ControlAction.Cancel, context, stopwatch.Elapsed, "Cancel accepted; local SBO ownership released.") : FromWriteFailure(Iec61850ControlAction.Cancel, write, null, context, stopwatch.Elapsed); @@ -691,7 +695,7 @@ private async Task BestEffortCancelAsync(CancellationToken cancellationToken) context, Descriptor.CancelSpecification, _options.RequireExactNamedControlFields); - await _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken).ConfigureAwait(false); + await MeasureServiceAsync(() => _transport.WriteControlAsync(Descriptor.References.Cancel, value, cancellationToken)).ConfigureAwait(false); } } catch @@ -782,6 +786,20 @@ private Iec61850ControlActionResult FromApplicationRejection( BuildWireStep(action, requestAccepted: true, clientMessage)) }; + // Serialized by _operationGate. These are local service boundaries, not + // packet capture timestamps; elapsed measurement uses the monotonic clock. + private async Task MeasureServiceAsync(Func> service) + { + _serviceStartedUtc = DateTimeOffset.UtcNow; + var started = Stopwatch.GetTimestamp(); + try { return await service().ConfigureAwait(false); } + finally + { + _serviceElapsed = Stopwatch.GetElapsedTime(started); + _serviceCompletedTimestamp = Stopwatch.GetTimestamp(); + } + } + private Iec61850ControlWireStep BuildWireStep( Iec61850ControlAction action, bool requestAccepted, @@ -803,7 +821,8 @@ private Iec61850ControlWireStep BuildWireStep( RequestAccepted = requestAccepted, RequestHex = _transport.LastRequestHex, ResponseHex = _transport.LastResponseHex, - Detail = detail + Detail = FormattableString.Invariant( + $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; timingScope=local-service-not-wire") }; } diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs index d5ec0490..387a415f 100644 --- a/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitor.cs @@ -637,6 +637,12 @@ private void RoutePersistentInformationReport(byte[] payload) return; } + // Control termination / LastApplError use the same MMS service but are + // not RCB reports. Never feed them to the single-monitor fallback. + // Their independent control subscribers already received the envelope. + if (!HasRcbReportHeader(decoded)) + return; + var header = MmsReportFrameMapper.DecodeHeader(decoded); var active = SnapshotPersistentReportMonitors(); var target = SelectPersistentReportMonitor(active, header, out var evidence); @@ -655,6 +661,11 @@ private void RoutePersistentInformationReport(byte[] payload) $"Routed InformationReport to {target.ReportControl.Reference} by {evidence}. RptID={RoutingTextOrDash(header.ReportId)}, DatSet={RoutingTextOrDash(header.DataSetReference)}."; } + internal static bool HasRcbReportHeader(MmsInformationReport report) + => report.IsSuccess && report.Items.Count >= 2 && + report.Items[0].Value?.Kind is MmsDataKind.VisibleString or MmsDataKind.MmsString && + report.Items[1].Value?.Kind == MmsDataKind.BitString; + internal static MmsPersistentReportMonitorSession? SelectPersistentReportMonitor( IReadOnlyList active, MmsReportHeader header, diff --git a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs index 856f2397..6482b3ee 100644 --- a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs +++ b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs @@ -69,6 +69,14 @@ public async Task SboEnhanced_PositiveTermination_PreservesSboWThenOperateWireSt Assert.True(result.WireSteps[1].RequestAccepted); Assert.Contains("positive CommandTermination", result.WireSteps[1].Detail, StringComparison.OrdinalIgnoreCase); + foreach (var step in result.WireSteps) + { + Assert.Contains("serviceStartedUtc=", step.Detail); + Assert.Contains("serviceMs=", step.Detail); + Assert.Contains("postResponseWaitMs=", step.Detail); + Assert.Contains("timingScope=local-service-not-wire", step.Detail); + } + Assert.Equal(2, transport.Writes.Count); Assert.EndsWith("$SBOw", transport.Writes[0].Reference.Item, StringComparison.Ordinal); Assert.EndsWith("$Oper", transport.Writes[1].Reference.Item, StringComparison.Ordinal); diff --git a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs index 2fccfc4b..34f7494c 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsPersistentReportRoutingTests.cs @@ -4,6 +4,53 @@ namespace AR.Iec61850.Tests.Mms; public sealed class MmsPersistentReportRoutingTests { + // Synthetic typed MMS evidence: no external packet capture or station data. + [Fact] + public void ControlInformationReportsCannotEnterSingleRcbFallback() + { + foreach (var value in new[] + { + MmsDataValue.Boolean(true), + MmsDataValue.Structure(new[] { MmsDataValue.VisibleString("LD0/CSWI1$CO$Pos$Oper"), MmsDataValue.Integer(0) }) + }) + { + var report = new MmsInformationReport + { + IsSuccess = true, + VariableReferences = new[] { "LD0/CSWI1$CO$Pos$Oper" }, + Items = new[] { new MmsInformationReportItem { Value = value } } + }; + Assert.False(MmsClientSession.HasRcbReportHeader(report)); + } + } + + [Fact] + public void RcbHeaderDoesNotRequireOptionalSequenceOrDatasetFields() + { + var report = new MmsInformationReport + { + IsSuccess = true, + Items = new[] + { + new MmsInformationReportItem { Value = MmsDataValue.VisibleString("RPT_A") }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(6, new byte[] { 0, 0 }) }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(7, new byte[] { 0x80 }) }, + new MmsInformationReportItem { Value = MmsDataValue.Boolean(true) } + } + }; + Assert.True(MmsClientSession.HasRcbReportHeader(report)); + Assert.False(MmsClientSession.HasRcbReportHeader(new MmsInformationReport())); + Assert.False(MmsClientSession.HasRcbReportHeader(new MmsInformationReport + { + IsSuccess = true, + Items = new[] + { + new MmsInformationReportItem { Value = MmsDataValue.Boolean(true) }, + new MmsInformationReportItem { Value = MmsDataValue.BitString(6, new byte[] { 0, 0 }) } + } + })); + } + [Fact] public void Selector_RoutesByExactReportIdBeforeDataSetProjection() { From 3aa0cafd958a4307f93512d201d7972cfee1b147 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:04:45 +0700 Subject: [PATCH 11/12] feat(p0-c): attribute SBOw latency to negative-indication observation gate without reducing safety --- .../Control/Iec61850ControlObjectSession.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs index 1018c50b..1bd7b268 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlObjectSession.cs @@ -814,6 +814,19 @@ private Iec61850ControlWireStep BuildWireStep( _ => Descriptor.References.Oper }; + // Distinguish protocol service response time from the selection + // negative-indication observation gate. The latter dominates the + // field sample (~400 ms); it must not be misreported as slow MMS I/O. + // This is telemetry only: do not reduce the grace period or bypass + // LastApplError and enhanced-security CommandTermination. + var postResponseStage = action switch + { + Iec61850ControlAction.SelectWithValue => "sbo-application-error-observation", + Iec61850ControlAction.Operate => "operate-termination-observation", + _ => "post-service-completion" + }; + var graceBudget = action == Iec61850ControlAction.SelectWithValue + ? _options.ApplicationErrorGracePeriod.TotalMilliseconds : 0d; return new Iec61850ControlWireStep { Action = action, @@ -822,7 +835,7 @@ private Iec61850ControlWireStep BuildWireStep( RequestHex = _transport.LastRequestHex, ResponseHex = _transport.LastResponseHex, Detail = FormattableString.Invariant( - $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; timingScope=local-service-not-wire") + $"{detail} serviceStartedUtc={_serviceStartedUtc:O}; serviceMs={_serviceElapsed.TotalMilliseconds:0.###}; postResponseWaitMs={Stopwatch.GetElapsedTime(_serviceCompletedTimestamp).TotalMilliseconds:0.###}; postResponseStage={postResponseStage}; configuredApplicationErrorGraceMs={graceBudget:0.###}; timingScope=local-service-not-wire") }; } From 9c7cb40364023a7b217e5ec31ab3105ba90142eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:05:37 +0700 Subject: [PATCH 12/12] test(p0-c): reject immediate and delayed SBOw LastApplError without sending Operate --- .../Control/G1OrderedWireEvidenceTests.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs index 6482b3ee..4071d179 100644 --- a/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs +++ b/tests/AR.Iec61850.Tests/Control/G1OrderedWireEvidenceTests.cs @@ -74,6 +74,8 @@ public async Task SboEnhanced_PositiveTermination_PreservesSboWThenOperateWireSt Assert.Contains("serviceStartedUtc=", step.Detail); Assert.Contains("serviceMs=", step.Detail); Assert.Contains("postResponseWaitMs=", step.Detail); + Assert.Contains("postResponseStage=", step.Detail); + Assert.Contains("configuredApplicationErrorGraceMs=", step.Detail); Assert.Contains("timingScope=local-service-not-wire", step.Detail); } @@ -82,6 +84,79 @@ public async Task SboEnhanced_PositiveTermination_PreservesSboWThenOperateWireSt Assert.EndsWith("$Oper", transport.Writes[1].Reference.Item, StringComparison.Ordinal); } + [Theory] + [InlineData(0)] + [InlineData(35)] + public async Task SboEnhanced_AsyncNegativeApplicationError_QuarantinesOperate( + int rejectionDelayMs) + { + // Synthetic independently constructed MMS indication; deliberately + // arrives AFTER successful SBOw service acceptance. A faster UI must + // not bypass this asynchronous rejection or emit the Operate write. + var transport = new WireEvidenceTransport(); + Task? rejection = null; + transport.OnWrite = (reference, _) => + { + if (!reference.Item.EndsWith("$SBOw", StringComparison.Ordinal)) + return; + rejection = Task.Run(async () => + { + await Task.Delay(rejectionDelayMs); + transport.EmitReport(reference, MmsDataValue.Structure( + [ + MmsDataValue.VisibleString("LD0/CSWI1.Pos"), + MmsDataValue.Integer(1), // control-error + MmsDataValue.Integer(10) // blocked-by-interlocking + ])); + }); + }; + + var references = Iec61850ControlObjectReferences.Parse("LD0/CSWI1.Pos"); + await using var session = new Iec61850ControlObjectSession( + transport, + new Iec61850ControlObjectDescriptor + { + ObjectReference = references.ObjectReference, + Cdc = "DPC", + ControlModel = Iec61850ControlModel.SelectBeforeOperateEnhanced, + CtlValSpecification = Type("ctlVal", "bit-string", 2), + OperSpecification = CommandSpecification(), + SelectWithValueSpecification = CommandSpecification(), + CancelSpecification = CommandSpecification(), + StatusReference = "LD0/CSWI1.Pos.stVal", + StatusFunctionalConstraint = "ST", + SboTimeout = TimeSpan.FromSeconds(2), + OperTimeout = TimeSpan.FromMilliseconds(250), + SupportsCommandTermination = true, + References = references + }, + new Iec61850ControlServiceOptions + { + DefaultOperateTimeout = TimeSpan.FromMilliseconds(250), + ApplicationErrorGracePeriod = TimeSpan.FromMilliseconds(120) + }); + var result = await session.OperateAsync(new Iec61850ControlRequest + { + ControlValue = Iec61850ControlValue.Close(), + Origin = Iec61850Origin.FromText("ARG1"), + InterlockCheck = true, + AutoSelect = true, + CommandTerminationTimeout = TimeSpan.FromMilliseconds(250) + }); + if (rejection is not null) + await rejection; + + Assert.Equal(Iec61850ControlCompletionState.Rejected, result.CompletionState); + Assert.True(result.RequestAccepted); // SBOw service did respond positively + Assert.False(result.CommandTerminationReceived); + Assert.Equal("blocked-by-interlocking", result.AddCause); + Assert.Single(transport.Writes); // no Operate, even with a delayed rejection + Assert.EndsWith("$SBOw", transport.Writes[0].Reference.Item, StringComparison.Ordinal); + var wire = Assert.Single(result.WireSteps); + Assert.Contains("postResponseStage=sbo-application-error-observation", wire.Detail); + Assert.Contains("configuredApplicationErrorGraceMs=120", wire.Detail); + } + private static MmsTypeSpecificationNode CommandSpecification() => Structure(string.Empty, Type("ctlVal", "bit-string", 2),