From 1591cff5172efe1ea08e5286e46181a67eda887b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:35:51 +0700 Subject: [PATCH 01/11] Add fail-closed report process value safety gate --- Services/ReportProcessValueSafety.cs | 126 +++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 Services/ReportProcessValueSafety.cs diff --git a/Services/ReportProcessValueSafety.cs b/Services/ReportProcessValueSafety.cs new file mode 100644 index 000000000..0ba8c90de --- /dev/null +++ b/Services/ReportProcessValueSafety.cs @@ -0,0 +1,126 @@ +using System.Text.RegularExpressions; + +namespace ArIED61850Tester.Services; + +/// +/// Final consumer-side safety boundary for event-driven report values. +/// The IEC 61850 engine remains authoritative for report decoding, but a report +/// sample that is impossible for the selected signal type must never overwrite a +/// previously verified process value or create a false SOE edge. Rejected report +/// samples are left to the existing MMS verification/fallback path. +/// +public static class ReportProcessValueSafety +{ + private static readonly Regex DbposBitString = new( + @"^\s*bits\(\s*(?:0x)?[0-9a-f]{2}\s*,\s*unused\s*=\s*6\s*\)\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public static bool IsSafe( + string? rawValue, + string? formattedValue, + string? dataType, + string? reference, + out string rejectionReason) + { + rejectionReason = string.Empty; + var raw = (rawValue ?? string.Empty).Trim(); + var formatted = (formattedValue ?? string.Empty).Trim(); + var type = (dataType ?? string.Empty).Trim(); + var signalReference = (reference ?? string.Empty).Trim(); + + if (raw.Length == 0 || formatted.Length == 0) + return true; + + var rawIsBits = raw.StartsWith("bits(", StringComparison.OrdinalIgnoreCase); + var formattedIsBits = formatted.StartsWith("bits(", StringComparison.OrdinalIgnoreCase); + var rawIsContainer = IsContainer(raw); + var formattedIsContainer = IsContainer(formatted); + + if (IsBoolean(type)) + { + if (rawIsBits || formattedIsBits) + return Reject($"Boolean signal {signalReference} received a BIT STRING report value.", out rejectionReason); + if (formattedIsContainer) + return Reject($"Boolean signal {signalReference} remained a structured report value after scalar projection.", out rejectionReason); + return true; + } + + if (IsDbpos(type)) + { + // A DPC/Dbpos process value is exactly two significant bits in one MMS + // BIT STRING octet (unused=6). Larger bitmaps are report metadata such as + // inclusion/OptFlds and must never be accepted as the process state. + if (rawIsBits && !DbposBitString.IsMatch(raw)) + return Reject($"DPC/Dbpos signal {signalReference} received a non-2-bit BIT STRING report value.", out rejectionReason); + if (formattedIsBits) + return Reject($"DPC/Dbpos signal {signalReference} could not normalize its BIT STRING report value.", out rejectionReason); + if (formattedIsContainer) + return Reject($"DPC/Dbpos signal {signalReference} remained a structured report value after scalar projection.", out rejectionReason); + return true; + } + + if (IsNativeBitString(type)) + return true; + + if (IsScalar(type)) + { + if (formattedIsBits) + return Reject($"Scalar signal {signalReference} received report BIT STRING metadata instead of its process value.", out rejectionReason); + if (formattedIsContainer) + return Reject($"Scalar signal {signalReference} remained a structured/array report value after projection.", out rejectionReason); + } + + // If metadata names are absent or vendor-specific, do not invent a type. + // The engine's strict frame mapper remains the primary authority. + // We only fail closed where ARSAS already has enough signal typing evidence. + _ = rawIsContainer; + return true; + } + + private static bool Reject(string reason, out string rejectionReason) + { + rejectionReason = reason; + return false; + } + + private static bool IsContainer(string value) + => value.StartsWith("Structure(", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Struct(", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("Array(", StringComparison.OrdinalIgnoreCase); + + private static bool IsBoolean(string dataType) + { + var normalized = dataType.Trim().ToLowerInvariant(); + return normalized is "bool" or "boolean" or "sps" or "singlepointstatus"; + } + + private static bool IsDbpos(string dataType) + { + var normalized = dataType.Trim().ToLowerInvariant(); + return normalized is "dbpos" or "dpc" or "doublepointstatus"; + } + + private static bool IsNativeBitString(string dataType) + { + var normalized = dataType.Trim().ToLowerInvariant().Replace(" ", string.Empty); + return normalized.Contains("bitstring", StringComparison.Ordinal) || + normalized is "bit-string" or "bits"; + } + + private static bool IsScalar(string dataType) + { + var normalized = dataType.Trim().ToLowerInvariant(); + if (normalized.Length == 0) + return false; + + return IsBoolean(dataType) || IsDbpos(dataType) || + normalized.Contains("int", StringComparison.Ordinal) || + normalized.Contains("uint", StringComparison.Ordinal) || + normalized.Contains("float", StringComparison.Ordinal) || + normalized.Contains("double", StringComparison.Ordinal) || + normalized.Contains("decimal", StringComparison.Ordinal) || + normalized.Contains("counter", StringComparison.Ordinal) || + normalized.Contains("bcr", StringComparison.Ordinal) || + normalized is "enum" or "enumerated" or "quality" or "timestamp"; + } +} From 90c6ca17fa32f61f34d8cd9f8c3cd4a749af74ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:36:05 +0700 Subject: [PATCH 02/11] Add report value safety regressions --- .../ReportProcessValueSafetyTests.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs diff --git a/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs b/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs new file mode 100644 index 000000000..902b9cc50 --- /dev/null +++ b/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs @@ -0,0 +1,107 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ReportProcessValueSafetyTests +{ + [Fact] + public void BooleanSignal_Rejects_ReportBitStringMetadata() + { + const string raw = "bits(0000, unused=6)"; + var formatted = Iec61850ValueFormatter.Format(raw, "Boolean", string.Empty); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Boolean", + "AA1C1F13R4DSQZ1/CILO1.EnaOpn.stVal", + out var reason); + + Assert.False(safe); + Assert.Contains("Boolean", reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("BIT STRING", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DbposSignal_Allows_ExactTwoBitProcessValue() + { + const string raw = "bits(40, unused=6)"; + var formatted = Iec61850ValueFormatter.Format(raw, "Dbpos", string.Empty); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Dbpos", + "AA1C1F13R4DSQZ1/CSWI1.Pos.stVal", + out var reason); + + Assert.True(safe, reason); + Assert.Equal("Open [01]", formatted); + } + + [Fact] + public void DbposSignal_Rejects_InclusionBitmapMasqueradingAsValue() + { + const string raw = "bits(FFFFFFFFF0, unused=4)"; + var formatted = Iec61850ValueFormatter.Format(raw, "Dbpos", string.Empty); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Dbpos", + "AA1C1F13R4DSQZ1/CSWI1.Pos.stVal", + out var reason); + + Assert.False(safe); + Assert.Contains("non-2-bit", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BooleanSignal_Allows_StructuredStVal_WhenFormatterProjectsScalar() + { + const string raw = "Structure(3) {stVal=false, q=bits(0000, unused=3), t=2026-08-16 04:00:00 UTC}"; + var formatted = Iec61850ValueFormatter.Format(raw, "Boolean", string.Empty); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Boolean", + "AA1C1F13R4ADD/GGIO6.CBOpnd.stVal", + out var reason); + + Assert.True(safe, reason); + Assert.Equal("False", formatted); + } + + [Fact] + public void ScalarSignal_Rejects_UnprojectedStructure() + { + const string raw = "Structure(3) {mag=123.4, q=bits(0000, unused=3), t=2026-08-16 04:00:00 UTC}"; + var formatted = Iec61850ValueFormatter.Format(raw, "Float", "A"); + + var safe = ReportProcessValueSafety.IsSafe( + raw, + formatted, + "Float", + "AA1C1F13R4RPRE_MMXU1/A.phsA.cVal.mag.f", + out var reason); + + Assert.False(safe); + Assert.Contains("structured", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void NativeBitStringSignal_RemainsAllowed() + { + const string raw = "bits(A0, unused=3)"; + + var safe = ReportProcessValueSafety.IsSafe( + raw, + raw, + "BitString", + "IEDLD0/GGIO1.SomeBits.stVal", + out var reason); + + Assert.True(safe, reason); + } +} From cc575df54084028ab8cfaa4bcb697c379fc7c1ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:36:39 +0700 Subject: [PATCH 03/11] Pin ARSAS to zero-OptFlds report fix engine --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 0662ab095..51f5571ba 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "e23b295b87760be8f7f0ce978a6987027ea50523", - "sourcePullRequest": 80, - "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, and PR #80 normalizes validated DataRef-enabled InformationReport ordering so metadata cannot shift into process values." + "commit": "becda399b4a3ae34831215fc915798b4f846c1be", + "sourcePullRequest": 81, + "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves unresolved static DataSet members, PR #77 canonicalizes cross-logical-device SCL references, PR #78 keeps one descriptor per static DataSet member while separating resolved runtime primary leaves from the original FCDA/FCD identity, PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp, PR #80 normalizes validated DataRef-enabled InformationReport ordering, and PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata so OptFlds/inclusion/reason fields can never leak into process values." } From 3d9f43d97cfa064afc277075f803ccdddcace762 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:36:57 +0700 Subject: [PATCH 04/11] Guard report value safety integration before event state mutation --- ...eportProcessValueSafetyIntegrationTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/ARSAS.Tests/ReportProcessValueSafetyIntegrationTests.cs diff --git a/tests/ARSAS.Tests/ReportProcessValueSafetyIntegrationTests.cs b/tests/ARSAS.Tests/ReportProcessValueSafetyIntegrationTests.cs new file mode 100644 index 000000000..a4ec1978f --- /dev/null +++ b/tests/ARSAS.Tests/ReportProcessValueSafetyIntegrationTests.cs @@ -0,0 +1,34 @@ +namespace ARSAS.Tests; + +public sealed class ReportProcessValueSafetyIntegrationTests +{ + [Fact] + public void ReportSafetyGate_RunsBefore_ReportValueMutation() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850MonitorRuntime.cs")); + + var gate = source.IndexOf("ReportProcessValueSafety.IsSafe", StringComparison.Ordinal); + var rejection = source.IndexOf("REPORT_VALUE_REJECTED", StringComparison.Ordinal); + var apply = source.IndexOf("ApplyValueUpdate(", gate >= 0 ? gate : 0, StringComparison.Ordinal); + + Assert.True(gate >= 0, "Report process-value safety gate is missing from the runtime report ingestion path."); + Assert.True(rejection > gate, "Rejected report values must emit explicit diagnostic evidence."); + Assert.True(apply > rejection, "Report value safety must execute before ApplyValueUpdate can mutate state or raise SOE events."); + Assert.Contains("state.ReportChangeVerified = false", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From 4b3c4551a4676617b1f0f0915f767f86638a5cbf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:37:18 +0700 Subject: [PATCH 05/11] Add temporary deterministic report safety integration patch --- scripts/patch-report-process-value-safety.py | 83 ++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 scripts/patch-report-process-value-safety.py diff --git a/scripts/patch-report-process-value-safety.py b/scripts/patch-report-process-value-safety.py new file mode 100644 index 000000000..ffd2addee --- /dev/null +++ b/scripts/patch-report-process-value-safety.py @@ -0,0 +1,83 @@ +from pathlib import Path + +runtime_path = Path("Services/Iec61850MonitorRuntime.cs") +lock_test_path = Path("tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs") + +runtime = runtime_path.read_text(encoding="utf-8") +lock_test = lock_test_path.read_text(encoding="utf-8") + +old_state = ''' public bool StaleReportSuppressedLogged { get; set; } + public int ConsecutiveErrors { get; set; }''' +new_state = ''' public bool StaleReportSuppressedLogged { get; set; } + public bool ReportValueRejectedLogged { get; set; } + public int ConsecutiveErrors { get; set; }''' +if runtime.count(old_state) != 1: + raise SystemExit(f"Runtime state anchor count={runtime.count(old_state)}") +runtime = runtime.replace(old_state, new_state, 1) + +old_ingest = ''' var state = session.States[point.PointKey]; + var display = update.HasValue + ? Iec61850ValueFormatter.Format(update.Value, point.IecDataType, point.Unit) + : state.Value; + if (update.HasValue && LooksLikeReferenceEcho(display, update.Reference, point.IecReference)) + continue; + + var receivedUtc = update.UpdatedAt == default ? DateTime.UtcNow : update.UpdatedAt.UtcDateTime;''' +new_ingest = ''' var state = session.States[point.PointKey]; + var display = update.HasValue + ? Iec61850ValueFormatter.Format(update.Value, point.IecDataType, point.Unit) + : state.Value; + if (update.HasValue && LooksLikeReferenceEcho(display, update.Reference, point.IecReference)) + continue; + + if (update.HasValue && !ReportProcessValueSafety.IsSafe( + update.Value, + display, + point.IecDataType, + point.IecReference, + out var rejectionReason)) + { + // A malformed/misaligned report is still useful as proof that the + // RCB is alive, but its process value is not authoritative. Do not + // mutate state or SOE history; keep MMS verification/fallback active. + state.ReportTrafficSeen = true; + state.LastReportUtc = DateTime.UtcNow; + state.ReportChangeVerified = false; + state.ReportMissLogged = false; + if (!state.ReportValueRejectedLogged) + { + state.ReportValueRejectedLogged = true; + var rawSummary = update.Value ?? "-"; + if (rawSummary.Length > 120) + rawSummary = rawSummary[..120] + "…"; + Log("WARN", session.Device.Name, + $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative."); + } + continue; + } + if (update.HasValue) + state.ReportValueRejectedLogged = false; + + var receivedUtc = update.UpdatedAt == default ? DateTime.UtcNow : update.UpdatedAt.UtcDateTime;''' +if runtime.count(old_ingest) != 1: + raise SystemExit(f"Report ingestion anchor count={runtime.count(old_ingest)}") +runtime = runtime.replace(old_ingest, new_ingest, 1) + +old_sha = 'Assert.Contains("e23b295b87760be8f7f0ce978a6987027ea50523", source, StringComparison.OrdinalIgnoreCase);' +new_sha = 'Assert.Contains("becda399b4a3ae34831215fc915798b4f846c1be", source, StringComparison.OrdinalIgnoreCase);' +old_pr = 'Assert.Contains("\\\"sourcePullRequest\\\": 80", source, StringComparison.Ordinal);' +new_pr = 'Assert.Contains("\\\"sourcePullRequest\\\": 81", source, StringComparison.Ordinal);' +for old, new in ((old_sha, new_sha), (old_pr, new_pr)): + if lock_test.count(old) != 1: + raise SystemExit(f"Lock test anchor count={lock_test.count(old)}: {old}") + lock_test = lock_test.replace(old, new, 1) + +anchor = ' Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase);\n' +addition = anchor + ' Assert.Contains("zero OptFlds", source, StringComparison.OrdinalIgnoreCase);\n Assert.Contains("quarantining unmapped canonical report metadata", source, StringComparison.OrdinalIgnoreCase);\n' +if lock_test.count(anchor) != 1: + raise SystemExit(f"Lock purpose anchor count={lock_test.count(anchor)}") +lock_test = lock_test.replace(anchor, addition, 1) + +runtime_path.write_text(runtime, encoding="utf-8", newline="\n") +lock_test_path.write_text(lock_test, encoding="utf-8", newline="\n") +print("Integrated report process-value safety before runtime state/SOE mutation and updated engine pin regression.") From 0b68672b7de37dfd381e0460131d585dc29029aa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:37:26 +0700 Subject: [PATCH 06/11] Run deterministic report process value safety patch --- ...temp-apply-report-process-value-safety.yml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/temp-apply-report-process-value-safety.yml diff --git a/.github/workflows/temp-apply-report-process-value-safety.yml b/.github/workflows/temp-apply-report-process-value-safety.yml new file mode 100644 index 000000000..314fc2884 --- /dev/null +++ b/.github/workflows/temp-apply-report-process-value-safety.yml @@ -0,0 +1,33 @@ +name: Temporary report process-value safety patch + +on: + push: + branches: + - fix/report-process-value-safety + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/report-process-value-safety + fetch-depth: 0 + - run: python scripts/patch-report-process-value-safety.py + - run: git diff --check + - name: Commit integrated safety boundary + shell: bash + run: | + if git diff --quiet; then + echo "Safety patch already applied." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Services/Iec61850MonitorRuntime.cs tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs + git commit -m "Reject impossible report values before SOE state mutation" + git push origin HEAD:fix/report-process-value-safety From f663c06eac1c03b85082b146303aa3186813ba33 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:37:35 +0000 Subject: [PATCH 07/11] Reject impossible report values before SOE state mutation --- Services/Iec61850MonitorRuntime.cs | 29 +++++++++++++++++++ ...neDataSetSignalSelectionRegressionTests.cs | 6 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 294041c7f..9307240b6 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -38,6 +38,7 @@ private sealed class RuntimePointState public bool AwaitingCommandReportEdge { get; set; } public bool CommandReportMissLogged { get; set; } public bool StaleReportSuppressedLogged { get; set; } + public bool ReportValueRejectedLogged { get; set; } public int ConsecutiveErrors { get; set; } } @@ -913,6 +914,34 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT if (update.HasValue && LooksLikeReferenceEcho(display, update.Reference, point.IecReference)) continue; + if (update.HasValue && !ReportProcessValueSafety.IsSafe( + update.Value, + display, + point.IecDataType, + point.IecReference, + out var rejectionReason)) + { + // A malformed/misaligned report is still useful as proof that the + // RCB is alive, but its process value is not authoritative. Do not + // mutate state or SOE history; keep MMS verification/fallback active. + state.ReportTrafficSeen = true; + state.LastReportUtc = DateTime.UtcNow; + state.ReportChangeVerified = false; + state.ReportMissLogged = false; + if (!state.ReportValueRejectedLogged) + { + state.ReportValueRejectedLogged = true; + var rawSummary = update.Value ?? "-"; + if (rawSummary.Length > 120) + rawSummary = rawSummary[..120] + "…"; + Log("WARN", session.Device.Name, + $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative."); + } + continue; + } + if (update.HasValue) + state.ReportValueRejectedLogged = false; + var receivedUtc = update.UpdatedAt == default ? DateTime.UtcNow : update.UpdatedAt.UtcDateTime; var hasSourceTimestamp = TryParseReportTimestampUtc(update.ReportTimestamp, out var reportSourceUtc); var commandValueMatches = update.HasValue && state.AwaitingCommandReportEdge && diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index ff13b7a0a..5cb5c3e37 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -72,11 +72,13 @@ public void EngineLock_PinsMergedReportProjectionEngineWithoutLosingMemberCentri { var source = File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")); - Assert.Contains("e23b295b87760be8f7f0ce978a6987027ea50523", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("\"sourcePullRequest\": 80", source, StringComparison.Ordinal); + Assert.Contains("becda399b4a3ae34831215fc915798b4f846c1be", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 81", source, StringComparison.Ordinal); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("zero OptFlds", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("quarantining unmapped canonical report metadata", source, StringComparison.OrdinalIgnoreCase); } [Fact] From b200b70890304b10460a8f5eecd656d97c3b6c18 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:37:53 +0700 Subject: [PATCH 08/11] Remove temporary report safety workflow --- ...temp-apply-report-process-value-safety.yml | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/temp-apply-report-process-value-safety.yml diff --git a/.github/workflows/temp-apply-report-process-value-safety.yml b/.github/workflows/temp-apply-report-process-value-safety.yml deleted file mode 100644 index 314fc2884..000000000 --- a/.github/workflows/temp-apply-report-process-value-safety.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Temporary report process-value safety patch - -on: - push: - branches: - - fix/report-process-value-safety - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/report-process-value-safety - fetch-depth: 0 - - run: python scripts/patch-report-process-value-safety.py - - run: git diff --check - - name: Commit integrated safety boundary - shell: bash - run: | - if git diff --quiet; then - echo "Safety patch already applied." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Services/Iec61850MonitorRuntime.cs tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs - git commit -m "Reject impossible report values before SOE state mutation" - git push origin HEAD:fix/report-process-value-safety From 2adaa7d1a5fae74b9293a5a2d46f8475e8e4db13 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:38:00 +0700 Subject: [PATCH 09/11] Remove temporary report safety patch script --- scripts/patch-report-process-value-safety.py | 83 -------------------- 1 file changed, 83 deletions(-) delete mode 100644 scripts/patch-report-process-value-safety.py diff --git a/scripts/patch-report-process-value-safety.py b/scripts/patch-report-process-value-safety.py deleted file mode 100644 index ffd2addee..000000000 --- a/scripts/patch-report-process-value-safety.py +++ /dev/null @@ -1,83 +0,0 @@ -from pathlib import Path - -runtime_path = Path("Services/Iec61850MonitorRuntime.cs") -lock_test_path = Path("tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs") - -runtime = runtime_path.read_text(encoding="utf-8") -lock_test = lock_test_path.read_text(encoding="utf-8") - -old_state = ''' public bool StaleReportSuppressedLogged { get; set; } - public int ConsecutiveErrors { get; set; }''' -new_state = ''' public bool StaleReportSuppressedLogged { get; set; } - public bool ReportValueRejectedLogged { get; set; } - public int ConsecutiveErrors { get; set; }''' -if runtime.count(old_state) != 1: - raise SystemExit(f"Runtime state anchor count={runtime.count(old_state)}") -runtime = runtime.replace(old_state, new_state, 1) - -old_ingest = ''' var state = session.States[point.PointKey]; - var display = update.HasValue - ? Iec61850ValueFormatter.Format(update.Value, point.IecDataType, point.Unit) - : state.Value; - if (update.HasValue && LooksLikeReferenceEcho(display, update.Reference, point.IecReference)) - continue; - - var receivedUtc = update.UpdatedAt == default ? DateTime.UtcNow : update.UpdatedAt.UtcDateTime;''' -new_ingest = ''' var state = session.States[point.PointKey]; - var display = update.HasValue - ? Iec61850ValueFormatter.Format(update.Value, point.IecDataType, point.Unit) - : state.Value; - if (update.HasValue && LooksLikeReferenceEcho(display, update.Reference, point.IecReference)) - continue; - - if (update.HasValue && !ReportProcessValueSafety.IsSafe( - update.Value, - display, - point.IecDataType, - point.IecReference, - out var rejectionReason)) - { - // A malformed/misaligned report is still useful as proof that the - // RCB is alive, but its process value is not authoritative. Do not - // mutate state or SOE history; keep MMS verification/fallback active. - state.ReportTrafficSeen = true; - state.LastReportUtc = DateTime.UtcNow; - state.ReportChangeVerified = false; - state.ReportMissLogged = false; - if (!state.ReportValueRejectedLogged) - { - state.ReportValueRejectedLogged = true; - var rawSummary = update.Value ?? "-"; - if (rawSummary.Length > 120) - rawSummary = rawSummary[..120] + "…"; - Log("WARN", session.Device.Name, - $"REPORT_VALUE_REJECTED: {point.SignalName} ({point.IecReference}) rejected report value '{rawSummary}'. {rejectionReason} MMS verification/fallback remains authoritative."); - } - continue; - } - if (update.HasValue) - state.ReportValueRejectedLogged = false; - - var receivedUtc = update.UpdatedAt == default ? DateTime.UtcNow : update.UpdatedAt.UtcDateTime;''' -if runtime.count(old_ingest) != 1: - raise SystemExit(f"Report ingestion anchor count={runtime.count(old_ingest)}") -runtime = runtime.replace(old_ingest, new_ingest, 1) - -old_sha = 'Assert.Contains("e23b295b87760be8f7f0ce978a6987027ea50523", source, StringComparison.OrdinalIgnoreCase);' -new_sha = 'Assert.Contains("becda399b4a3ae34831215fc915798b4f846c1be", source, StringComparison.OrdinalIgnoreCase);' -old_pr = 'Assert.Contains("\\\"sourcePullRequest\\\": 80", source, StringComparison.Ordinal);' -new_pr = 'Assert.Contains("\\\"sourcePullRequest\\\": 81", source, StringComparison.Ordinal);' -for old, new in ((old_sha, new_sha), (old_pr, new_pr)): - if lock_test.count(old) != 1: - raise SystemExit(f"Lock test anchor count={lock_test.count(old)}: {old}") - lock_test = lock_test.replace(old, new, 1) - -anchor = ' Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase);\n' -addition = anchor + ' Assert.Contains("zero OptFlds", source, StringComparison.OrdinalIgnoreCase);\n Assert.Contains("quarantining unmapped canonical report metadata", source, StringComparison.OrdinalIgnoreCase);\n' -if lock_test.count(anchor) != 1: - raise SystemExit(f"Lock purpose anchor count={lock_test.count(anchor)}") -lock_test = lock_test.replace(anchor, addition, 1) - -runtime_path.write_text(runtime, encoding="utf-8", newline="\n") -lock_test_path.write_text(lock_test, encoding="utf-8", newline="\n") -print("Integrated report process-value safety before runtime state/SOE mutation and updated engine pin regression.") From d5adbc614e038b22ba571821a298d1fc689ed082 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:38:33 +0700 Subject: [PATCH 10/11] Preserve legitimate quality bit-string report values --- Services/ReportProcessValueSafety.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Services/ReportProcessValueSafety.cs b/Services/ReportProcessValueSafety.cs index 0ba8c90de..1e07cf4f8 100644 --- a/Services/ReportProcessValueSafety.cs +++ b/Services/ReportProcessValueSafety.cs @@ -33,7 +33,6 @@ public static bool IsSafe( var rawIsBits = raw.StartsWith("bits(", StringComparison.OrdinalIgnoreCase); var formattedIsBits = formatted.StartsWith("bits(", StringComparison.OrdinalIgnoreCase); - var rawIsContainer = IsContainer(raw); var formattedIsContainer = IsContainer(formatted); if (IsBoolean(type)) @@ -59,6 +58,8 @@ public static bool IsSafe( return true; } + // Quality and explicitly typed BIT STRING DataAttributes legitimately use MMS + // BIT STRING encoding. Do not mistake those process values for report metadata. if (IsNativeBitString(type)) return true; @@ -73,7 +74,6 @@ public static bool IsSafe( // If metadata names are absent or vendor-specific, do not invent a type. // The engine's strict frame mapper remains the primary authority. // We only fail closed where ARSAS already has enough signal typing evidence. - _ = rawIsContainer; return true; } @@ -104,7 +104,7 @@ private static bool IsNativeBitString(string dataType) { var normalized = dataType.Trim().ToLowerInvariant().Replace(" ", string.Empty); return normalized.Contains("bitstring", StringComparison.Ordinal) || - normalized is "bit-string" or "bits"; + normalized is "bit-string" or "bits" or "quality"; } private static bool IsScalar(string dataType) @@ -121,6 +121,6 @@ private static bool IsScalar(string dataType) normalized.Contains("decimal", StringComparison.Ordinal) || normalized.Contains("counter", StringComparison.Ordinal) || normalized.Contains("bcr", StringComparison.Ordinal) || - normalized is "enum" or "enumerated" or "quality" or "timestamp"; + normalized is "enum" or "enumerated" or "timestamp"; } } From f51b64642934c7fc7c9721b09decdc48aa93c37c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 16 Aug 2026 11:38:54 +0700 Subject: [PATCH 11/11] Protect legitimate quality bit-string report values --- .../ARSAS.Tests/ReportProcessValueSafetyTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs b/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs index 902b9cc50..a92729858 100644 --- a/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs +++ b/tests/ARSAS.Tests/ReportProcessValueSafetyTests.cs @@ -104,4 +104,19 @@ public void NativeBitStringSignal_RemainsAllowed() Assert.True(safe, reason); } + + [Fact] + public void QualitySignal_RemainsAllowedAsNativeBitString() + { + const string raw = "bits(0000, unused=3)"; + + var safe = ReportProcessValueSafety.IsSafe( + raw, + raw, + "Quality", + "IEDLD0/GGIO1.Ind1.q", + out var reason); + + Assert.True(safe, reason); + } }