Resolve Contracts-002 code-review finding

MxCommandReply.payload has no by-name ack case: MX_COMMAND_KIND_ACKNOWLEDGE_
ALARM_BY_NAME reuses the acknowledge_alarm reply payload. Verified the worker
(MxAccessCommandExecutor.ExecuteAcknowledgeAlarmByName) and gateway
(WorkerAlarmRpcDispatcher) already implement this correctly — the gap was
purely undocumented contract asymmetry. Documented the reuse on the proto
oneof case and the AcknowledgeAlarmReplyPayload message comment (regenerating
the .NET contract), and in docs/AlarmClientDiscovery.md. Added
ProtobufContractRoundTripTests.MxCommandReply_AcknowledgeAlarmByName_Reuses
AcknowledgeAlarmPayloadCase to pin the contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 21:50:57 -04:00
parent 6a4833bd32
commit 1f546c46ee
5 changed files with 107 additions and 15 deletions
@@ -13388,6 +13388,17 @@ namespace MxGateway.Contracts.Proto {
/// <summary>Field number for the "acknowledge_alarm" field.</summary>
public const int AcknowledgeAlarmFieldNumber = 34;
/// <summary>
/// Reply payload for BOTH MX_COMMAND_KIND_ACKNOWLEDGE_ALARM (by GUID)
/// and MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME. There is intentionally
/// no by-name-specific reply case: the by-name ack carries no outcome
/// detail beyond the native ack return code, so the worker reuses this
/// `acknowledge_alarm` payload for both command kinds (the worker's
/// MxAccessCommandExecutor sets `acknowledge_alarm` for the by-name arm
/// too). Consumers must dispatch on MxCommandReply.kind, not on the
/// payload case, to tell the two acks apart. The top-level `hresult`
/// mirrors AcknowledgeAlarmReplyPayload.native_status and is preferred.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public global::MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload AcknowledgeAlarm {
@@ -17339,12 +17350,16 @@ namespace MxGateway.Contracts.Proto {
}
/// <summary>
/// Reply payload for AcknowledgeAlarmCommand. Surfaces AVEVA's native
/// AlarmAckByGUID return code; 0 means success. The MxCommandReply's
/// hresult field carries the same value and is preferred for protocol
/// consumers — this payload exists so the gateway-side
/// WorkerAlarmRpcDispatcher can echo native_status into
/// AcknowledgeAlarmReply.hresult without unpacking the outer envelope.
/// Reply payload for AcknowledgeAlarmCommand AND
/// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
/// payload case (`MxCommandReply.acknowledge_alarm`); there is no
/// dedicated by-name reply case. Surfaces AVEVA's native ack return
/// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
/// by-name arm); 0 means success. The MxCommandReply's hresult field
/// carries the same value and is preferred for protocol consumers —
/// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
/// can echo native_status into AcknowledgeAlarmReply.hresult without
/// unpacking the outer envelope.
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
public sealed partial class AcknowledgeAlarmReplyPayload : pb::IMessage<AcknowledgeAlarmReplyPayload>
@@ -381,6 +381,15 @@ message MxCommandReply {
BulkSubscribeReply un_advise_item_bulk = 31;
BulkSubscribeReply subscribe_bulk = 32;
BulkSubscribeReply unsubscribe_bulk = 33;
// Reply payload for BOTH MX_COMMAND_KIND_ACKNOWLEDGE_ALARM (by GUID)
// and MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME. There is intentionally
// no by-name-specific reply case: the by-name ack carries no outcome
// detail beyond the native ack return code, so the worker reuses this
// `acknowledge_alarm` payload for both command kinds (the worker's
// MxAccessCommandExecutor sets `acknowledge_alarm` for the by-name arm
// too). Consumers must dispatch on MxCommandReply.kind, not on the
// payload case, to tell the two acks apart. The top-level `hresult`
// mirrors AcknowledgeAlarmReplyPayload.native_status and is preferred.
AcknowledgeAlarmReplyPayload acknowledge_alarm = 34;
QueryActiveAlarmsReplyPayload query_active_alarms = 35;
SessionStateReply session_state = 100;
@@ -448,12 +457,16 @@ message DrainEventsReply {
repeated MxEvent events = 1;
}
// Reply payload for AcknowledgeAlarmCommand. Surfaces AVEVA's native
// AlarmAckByGUID return code; 0 means success. The MxCommandReply's
// hresult field carries the same value and is preferred for protocol
// consumers — this payload exists so the gateway-side
// WorkerAlarmRpcDispatcher can echo native_status into
// AcknowledgeAlarmReply.hresult without unpacking the outer envelope.
// Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
// by-name arm); 0 means success. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers —
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
// can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope.
message AcknowledgeAlarmReplyPayload {
int32 native_status = 1;
}
@@ -342,6 +342,56 @@ public sealed class ProtobufContractRoundTripTests
Assert.True(parsed.HasHresult);
}
/// <summary>
/// Pins the documented command/reply payload-reuse contract: an
/// <c>ACKNOWLEDGE_ALARM_BY_NAME</c> command's reply intentionally has no
/// by-name-specific payload case and instead reuses the
/// <c>acknowledge_alarm</c> (<see cref="AcknowledgeAlarmReplyPayload"/>)
/// case. A future change that adds a separate by-name reply case — or
/// drops the reuse — breaks this test. See Contracts-002 and
/// docs/AlarmClientDiscovery.md section 4.
/// </summary>
[Fact]
public void MxCommandReply_AcknowledgeAlarmByName_ReusesAcknowledgeAlarmPayloadCase()
{
// The reply oneof must NOT have a by-name-specific case. If a future
// edit adds one, this assertion fails and forces the doc/test contract
// to be revisited deliberately.
foreach (MxCommandReply.PayloadOneofCase value in
System.Enum.GetValues<MxCommandReply.PayloadOneofCase>())
{
Assert.NotEqual("AcknowledgeAlarmByName", value.ToString());
}
var original = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-7",
Kind = MxCommandKind.AcknowledgeAlarmByName,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
// By-name ack reuses the acknowledge_alarm payload case; see the
// worker's MxAccessCommandExecutor.ExecuteAcknowledgeAlarmByName.
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = 0,
},
};
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
// Kind distinguishes the by-name ack; the payload case is shared.
Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, parsed.Kind);
Assert.Equal(MxCommandReply.PayloadOneofCase.AcknowledgeAlarm, parsed.PayloadCase);
Assert.Equal(0, parsed.AcknowledgeAlarm.NativeStatus);
// The by-name command has its own command payload case — the asymmetry
// with the reply oneof is the documented contract under test.
Assert.Contains(
MxCommand.PayloadOneofCase.AcknowledgeAlarmByNameCommand,
System.Enum.GetValues<MxCommand.PayloadOneofCase>());
}
/// <summary>Verifies that ActiveAlarmSnapshot round-trips with current state and operator metadata.</summary>
[Fact]
public void ActiveAlarmSnapshot_RoundTripsAllFields()