fix(galaxy): #866 map MxStatus failure details by the MxStatusDetail vocabulary, not as OPC DA quality

The gateway copies MXSTATUS_PROXY.detail verbatim, so a failure row's detail is
always an MxStatusDetail code. FromMxStatus routed any detail <= 0xFF through the
OPC DA quality table: MX_E_PlatformCommunicationError (2) on a stopped platform's
ScanState logged "Unrecognised MXAccess quality byte 0x02" and published generic
Bad (VD03 live capture), MX_E_InvalidObjectId (8) became BadNotConnected,
MX_E_Overflow (20) BadCommunicationError, and a detail in the Uncertain band
published Uncertain for a failure.

Every non-zero detail now maps per the gateway.md vocabulary (timeout,
communication, configuration, type, range, access, initialising), then by
category; the category-fallback warning logs once per (detail, category).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 15:06:26 -05:00
parent 7a27ef1f67
commit 71b3b7f35f
2 changed files with 100 additions and 40 deletions
@@ -110,8 +110,8 @@ internal static class StatusCodeMap
/// <see cref="MxStatusProxyExtensions.IsSuccess"/> (category == OK AND success != 0)
/// as the canonical success test — the proto contract explicitly documents that
/// <c>success</c> is NOT a boolean and must not be checked in isolation; category is
/// the authoritative indicator. On failure, the detail byte (OPC DA quality substatus)
/// drives the specific code, with a transport-error fallback for pre-MXAccess failures.
/// the authoritative indicator. On failure, the <c>MxStatusDetail</c> code drives the specific
/// code, then the category, with a transport-error fallback for pre-MXAccess failures.
/// </summary>
/// <param name="status">The MX status proxy to convert, or null for Good status.</param>
/// <param name="logger">Optional logger for diagnostics on unknown status codes.</param>
@@ -121,21 +121,14 @@ internal static class StatusCodeMap
if (status is null) return Good;
if (status.IsSuccess()) return Good;
// A detail in [1, 255] is an OPC DA quality substatus (read/data-change rows). Anything
// larger is an MXAccess MxStatus detail code — the write-completion rows the gateway
// correlates onto write replies carry these (e.g. 1007 operational, 1008 security) — and
// truncating one into the quality table maps a REFUSED WRITE to Good whenever the low byte
// lands in the Good band (1007 → 239 ≥ 192). Route those on the status category instead.
var detail = status.Detail;
if (detail is > 0 and <= 0xFF)
{
var mapped = FromQualityByte((byte)detail, logger);
// Invariant: a row that is not IsSuccess() must never surface as Good — a
// contradictory Good-band substatus on a failure row falls back to the category.
if ((mapped >> 30) != 0u) return mapped;
return FromCategory(status, logger);
}
if (detail > 0xFF) return FromCategory(status, logger);
// The detail is MXAccess's MxStatusDetail code verbatim (mxaccessgw's MxStatusProxyConverter copies
// MXSTATUS_PROXY.detail straight through; vocabulary in its gateway.md) — NEVER an OPC DA quality
// byte. It used to be routed through the quality table whenever it was <= 0xFF, which mis-named
// every low code: MX_E_PlatformCommunicationError (2) logged as "quality byte 0x02" and fell to
// generic Bad (#866, a stopped platform's ScanState on VD03), MX_E_InvalidObjectId (8) surfaced as
// BadNotConnected, MX_E_Overflow (20) as BadCommunicationError. A failure row is also never Good or
// Uncertain, whatever its detail.
if (status.Detail != 0) return FromCategory(status, logger);
// detected_by != Mxaccess (raw_detected_by != the MXAccess source enum) implies
// the failure happened pre-call (gateway, worker, transport) — surface as a
@@ -146,33 +139,47 @@ internal static class StatusCodeMap
}
/// <summary>
/// Map a failure row whose detail field is an MxStatus detail code rather than an OPC DA
/// quality byte (or contradicts the failure). Per-code first — the <c>MxStatusDetail</c>
/// vocabulary is published in mxaccessgw's gateway.md ("MxStatus Detail Vocabulary",
/// <c>75c71ad</c>, lifted from <c>Interop.aaMxDataConsumer.MxStatusDetail</c>) — then the
/// category bucket. Codes ≤ 0xFF (e.g. 1 = MX_E_RequestTimedOut) never reach here: they are
/// value-ambiguous with the quality-byte space and take the quality path, which still lands
/// them in the Bad family.
/// Map a failure row by its <c>MxStatusDetail</c> code — the vocabulary is published in mxaccessgw's
/// gateway.md ("MxStatus Detail Vocabulary", lifted from <c>Interop.aaMxDataConsumer.MxStatusDetail</c>)
/// — then by the status category for codes with no specific OPC UA counterpart.
/// </summary>
private static uint FromCategory(MxStatusProxy status, ILogger? logger)
{
switch (status.Detail)
{
case 1004: return BadOutOfRange; // MX_E_DataOutOfRange
case 1: return BadTimeout; // MX_E_RequestTimedOut
case 2: // MX_E_PlatformCommunicationError (live: stopped platform, #866)
case 5: return BadCommunicationError; // MX_E_EngineCommunicationError
case 3: // MX_E_InvalidPlatformId
case 4: // MX_E_InvalidEngineId
case 6: // MX_E_InvalidReference
case 8: // MX_E_InvalidObjectId
case 9: // MX_E_ObjectSignatureMismatch
case 10: // MX_E_AttributeSignatureMismatch
case 1000: // MX_E_InvalidPrimitiveId
case 1001: // MX_E_InvalidAttributeId
case 1002: return BadConfigurationError; // MX_E_InvalidPropertyId
case 13: // MX_E_WrongDataType
case 18: // MX_E_ConversionNotSupported
case 19: // MX_E_UnableToConvertString
case 1005: return BadTypeMismatch; // MX_E_IncorrectDataType
case 20: // MX_E_Overflow
case 1004: return BadOutOfRange; // MX_E_DataOutOfRange
case 1006: return BadNotReadable; // MX_E_NotReadable
case 1007: return BadNotWritable; // MX_E_NotWriteable (live-captured: read-only attr)
case 1008: // MX_E_WriteAccessDenied (live-captured: unadvised no-login write)
case 1012: // MX_E_SecuredWrite (attribute requires a secured write)
case 1013: // MX_E_VerifiedWrite (attribute requires a verified write)
case 1014: // MX_E_NoAlarmAckPrivilege
case 1016: // MX_E_UserNotHavingAccessRights
case 1017: return BadUserAccessDenied; // MX_E_VerifierNotHavingVerifyRights
case 1010: // MX_E_ObjectInitializing (transient)
case 1011: return BadOutOfService; // MX_E_EngineInitializing (transient)
}
// Everything else — including 8000 MX_E_AutomationObjectSpecificError, deliberately, since
// the object supplies its own semantics — falls back to the category bucket.
// Everything else — resolving (11/12), repository/engine busy (7/25/26), version/command mismatches,
// index errors, 1009 unknown and, deliberately, 8000 MX_E_AutomationObjectSpecificError since the
// object supplies its own semantics — falls back to the category bucket.
var code = status.Category switch
{
MxStatusCategory.SecurityError => BadUserAccessDenied,
@@ -180,13 +187,19 @@ internal static class StatusCodeMap
MxStatusCategory.ConfigurationError => BadConfigurationError,
_ => Bad,
};
logger?.LogWarning(
"MxStatus failure row category {Category} detail {Detail} mapped to 0x{Code:X8} at category " +
"level. Field capture welcome — extend StatusCodeMap.FromCategory with the detail code.",
status.Category, status.Detail, code);
// Once per (detail, category) per process: these rows arrive on the data-change path, so a stopped
// engine's tags would otherwise log on every update.
if (logger is not null && UnmappedDetailsLogged.TryAdd((status.Detail, status.Category), 0))
logger.LogWarning(
"MxStatus failure row category {Category} detail {Detail} ({DiagnosticText}) mapped to 0x{Code:X8} at " +
"category level. Field capture welcome — extend StatusCodeMap.FromCategory with the detail code.",
status.Category, status.Detail, status.DiagnosticText, code);
return code;
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(int, MxStatusCategory), byte>
UnmappedDetailsLogged = new();
/// <summary>
/// Convert an OPC UA status-code uint back to the OPC DA quality category
/// byte — Good=192, Uncertain=64, Bad=0 — by extracting the top-two bits of the
@@ -84,12 +84,60 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromMxStatus(s).ShouldNotBe(StatusCodeMap.Good);
}
/// <summary>Verifies that known detail codes map to their specific status codes.</summary>
/// <summary>
/// #866: a low detail is an MxStatusDetail code, not an OPC DA quality byte. Detail 8 is
/// MX_E_InvalidObjectId — it used to surface as BadNotConnected (quality byte 8).
/// </summary>
[Fact]
public void FromMxStatus_SuccessZeroAndCategoryNotOk_DetailKnown_MapsToSpecificCode()
{
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 /* Bad_NotConnected */ };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadNotConnected);
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadConfigurationError);
}
/// <summary>
/// #866 live capture (VD03 2026-11-11): reading <c>JoePlatform.ScanState</c> on a deployed but
/// not-running platform returns a failure row with detail 2 = MX_E_PlatformCommunicationError,
/// which logged "Unrecognised MXAccess quality byte 0x02" and published generic Bad.
/// </summary>
[Fact]
public void FromMxStatus_PlatformCommunicationError_IsCommunicationError_AndDoesNotLogAsQuality()
{
var logger = new CapturingLogger();
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.CommunicationError, Detail = 2 };
StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.BadCommunicationError);
logger.Entries.ShouldBeEmpty();
}
/// <summary>Low MxStatusDetail codes map per the gateway.md vocabulary regardless of category.</summary>
[Theory]
[InlineData(1, 0x800A0000u)] // MX_E_RequestTimedOut → BadTimeout
[InlineData(2, 0x80050000u)] // MX_E_PlatformCommunicationError → BadCommunicationError
[InlineData(5, 0x80050000u)] // MX_E_EngineCommunicationError → BadCommunicationError
[InlineData(3, 0x80890000u)] // MX_E_InvalidPlatformId → BadConfigurationError
[InlineData(4, 0x80890000u)] // MX_E_InvalidEngineId (was BadConfigurationError by quality-byte accident)
[InlineData(6, 0x80890000u)] // MX_E_InvalidReference
[InlineData(12, 0x80000000u)] // MX_E_ResolvingObject → category (was BadDeviceFailure)
[InlineData(13, 0x80740000u)] // MX_E_WrongDataType → BadTypeMismatch
[InlineData(16, 0x80000000u)] // MX_E_IndexOutOfOrder → category (was BadSensorFailure)
[InlineData(19, 0x80740000u)] // MX_E_UnableToConvertString → BadTypeMismatch
[InlineData(20, 0x803C0000u)] // MX_E_Overflow → BadOutOfRange (was BadCommunicationError)
[InlineData(24, 0x80000000u)] // MX_E_LmxInvalidCommand → category (was BadOutOfService)
[InlineData(32, 0x80000000u)] // not a vocabulary code → category (was BadWaitingForInitialData)
public void FromMxStatus_LowDetailCodes_MapByVocabulary(int detail, uint expected)
{
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = detail };
StatusCodeMap.FromMxStatus(s).ShouldBe(expected);
}
/// <summary>The category fallback logs once per (detail, category), not on every data change.</summary>
[Fact]
public void FromMxStatus_CategoryFallback_LogsOncePerDetailAndCategory()
{
var logger = new CapturingLogger();
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SoftwareError, Detail = 23 /* MX_E_LmxVersionMismatch */ };
for (var i = 0; i < 5; i++) StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.Bad);
logger.Entries.Count.ShouldBe(1);
}
/// <summary>Verifies that non-zero DetectedBy maps to communication error when detail is zero.</summary>
@@ -155,9 +203,8 @@ public sealed class StatusCodeMapTests
}
/// <summary>
/// Invariant: a row that is not IsSuccess() must NEVER map to Good — a Good-band quality
/// byte (e.g. 216 = GoodLocalOverride) on a failure row is contradictory and falls back to
/// the category route. Uncertain-band substatuses remain allowed (uncertain is not Good).
/// Invariant: a row that is not IsSuccess() must NEVER map to Good — a detail that happens to sit in
/// the OPC DA Good band (216) is not a quality byte and routes on the category.
/// </summary>
[Fact]
public void FromMxStatus_FailureRow_GoodBandQualityByte_NeverMapsGood()
@@ -166,12 +213,12 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
}
/// <summary>An Uncertain-band quality substatus on a failure row still maps to its specific Uncertain code.</summary>
/// <summary>A failure row is Bad: a detail in the OPC DA Uncertain band (68) is not a quality byte (#866).</summary>
[Fact]
public void FromMxStatus_FailureRow_UncertainBandQualityByte_KeepsUncertainCode()
public void FromMxStatus_FailureRow_UncertainBandDetail_IsBad()
{
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 68 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.UncertainLastUsableValue);
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
}
/// <summary>Verifies that top two bits of status codes follow OPC UA convention.</summary>