Resolve Contracts-001/004/005/006/007/008 code-review findings

Contracts-001: docs/Grpc.md still described "four MxAccessGateway RPCs" —
updated to the actual six (adding AcknowledgeAlarm and QueryActiveAlarms to
the handler and validation-rule sections).

Contracts-003 (Won't Fix): the finding is factually wrong — the <Protobuf>
item for mxaccess_worker.proto already sets ProtoRoot="Protos"; all three
items are consistent (confirmed back to the reviewed commit).

Contracts-004: corrected the stale GatewayContractInfo XML summary
("before generated protobuf contracts are introduced").

Contracts-005: no proto field/enum value was ever removed, so no reserved
ranges were invented. Added a wire-compatibility policy comment to all three
.proto files instructing future editors to reserve removed numbers.

Contracts-006: documented MxStatusProxy.success — it mirrors the COM
MXSTATUS_PROXY numeric success member, is not a boolean, and clients should
branch on category.

Contracts-007: added 13 round-trip tests covering galaxy_repository.proto
messages, bulk-subscribe payloads, and raw-value/IPC worker bodies.

Contracts-008: WorkerAlarmRpcDispatcher never assigns AcknowledgeAlarmReply.
status, so the old "native status" proto comment was misleading. Corrected
the hresult/status proto comments and documented the worker native_status →
public reply mapping in AlarmClientDiscovery.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 23:12:00 -04:00
parent 771229b39f
commit ee959e46e6
9 changed files with 448 additions and 24 deletions
@@ -1,8 +1,10 @@
namespace MxGateway.Contracts;
/// <summary>
/// Exposes version metadata shared by gateway components before generated
/// protobuf contracts are introduced.
/// Holds the protocol version constants shared by gateway components.
/// <see cref="GatewayProtocolVersion"/> is advertised to clients in
/// <c>OpenSessionReply</c>; <see cref="WorkerProtocolVersion"/> is used to
/// validate <c>WorkerEnvelope</c> protocol framing on the gateway↔worker pipe.
/// </summary>
public static class GatewayContractInfo
{
@@ -21418,7 +21418,12 @@ namespace MxGateway.Contracts.Proto {
private int hresult_;
/// <summary>
/// HRESULT captured from MXAccess if the ack failed at the COM layer.
/// Native ack return code echoed from the worker. The worker carries the
/// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
/// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
/// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
/// ack-outcome field for the public RPC. Absent only when the worker reply
/// omitted the value entirely (a protocol violation).
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
@@ -21446,7 +21451,11 @@ namespace MxGateway.Contracts.Proto {
public const int StatusFieldNumber = 5;
private global::MxGateway.Contracts.Proto.MxStatusProxy status_;
/// <summary>
/// Native MxAccess status describing the outcome of the ack.
/// Reserved for a structured MxStatusProxy view of the ack outcome. The
/// worker by-name/by-GUID ack path produces only the int32 return code
/// (see `hresult`), so the current gateway leaves this field UNSET on every
/// reply. Clients must read `hresult` (and `protocol_status`) for the ack
/// result and must not depend on `status` being populated.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
@@ -22078,6 +22087,17 @@ namespace MxGateway.Contracts.Proto {
/// <summary>Field number for the "success" field.</summary>
public const int SuccessFieldNumber = 1;
private int success_;
/// <summary>
/// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
/// (a 16-bit signed value in the COM struct, widened to int32 on the
/// wire). Despite the name it is NOT a boolean — it is the raw numeric
/// indicator the worker reads off the COM struct without reinterpretation.
/// It is carried verbatim for diagnostics; the authoritative success/
/// failure of the operation is `category` (MX_STATUS_CATEGORY_OK marks
/// success), with `detail`, `diagnostic_text`, `raw_category`, and
/// `raw_detected_by` describing any non-OK outcome. Clients should branch
/// on `category`, not on a specific `success` value.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int Success {
@@ -7,6 +7,13 @@ option csharp_namespace = "MxGateway.Contracts.Proto.Galaxy";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
// additively only. Never renumber or repurpose an existing field number or
// enum value. When a field or enum value is removed, add a `reserved` range
// (and `reserved` name) covering it in the same change so a future editor
// cannot accidentally reuse the retired tag. There are no `reserved`
// declarations today because no field or enum value has ever been removed.
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
@@ -7,6 +7,13 @@ option csharp_namespace = "MxGateway.Contracts.Proto";
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
// additively only. Never renumber or repurpose an existing field number or
// enum value. When a field or enum value is removed, add a `reserved` range
// (and `reserved` name) covering it in the same change so a future editor
// cannot accidentally reuse the retired tag. There are no `reserved`
// declarations today because no field or enum value has ever been removed.
// Public client API for MXAccess sessions hosted by the gateway.
service MxAccessGateway {
rpc OpenSession(OpenSessionRequest) returns (OpenSessionReply);
@@ -641,9 +648,18 @@ message AcknowledgeAlarmReply {
string session_id = 1;
string correlation_id = 2;
ProtocolStatus protocol_status = 3;
// HRESULT captured from MXAccess if the ack failed at the COM layer.
// Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation).
optional int32 hresult = 4;
// Native MxAccess status describing the outcome of the ack.
// Reserved for a structured MxStatusProxy view of the ack outcome. The
// worker by-name/by-GUID ack path produces only the int32 return code
// (see `hresult`), so the current gateway leaves this field UNSET on every
// reply. Clients must read `hresult` (and `protocol_status`) for the ack
// result and must not depend on `status` being populated.
MxStatusProxy status = 5;
string diagnostic_message = 6;
}
@@ -657,6 +673,15 @@ message QueryActiveAlarmsRequest {
}
message MxStatusProxy {
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
// (a 16-bit signed value in the COM struct, widened to int32 on the
// wire). Despite the name it is NOT a boolean — it is the raw numeric
// indicator the worker reads off the COM struct without reinterpretation.
// It is carried verbatim for diagnostics; the authoritative success/
// failure of the operation is `category` (MX_STATUS_CATEGORY_OK marks
// success), with `detail`, `diagnostic_text`, `raw_category`, and
// `raw_detected_by` describing any non-OK outcome. Clients should branch
// on `category`, not on a specific `success` value.
int32 success = 1;
MxStatusCategory category = 2;
MxStatusSource detected_by = 3;
@@ -8,6 +8,13 @@ import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
import "mxaccess_gateway.proto";
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
// additively only. Never renumber or repurpose an existing field number or
// enum value. When a field or enum value is removed, add a `reserved` range
// (and `reserved` name) covering it in the same change so a future editor
// cannot accidentally reuse the retired tag. There are no `reserved`
// declarations today because no field or enum value has ever been removed.
// Gateway-to-worker IPC envelope. Named-pipe framing prepends a little-endian
// uint32 payload length to this protobuf payload.
message WorkerEnvelope {
@@ -2,6 +2,7 @@ using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Tests.Contracts;
@@ -439,4 +440,334 @@ public sealed class ProtobufContractRoundTripTests
Assert.Equal(withoutFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray()));
Assert.Equal(withFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray()));
}
/// <summary>Verifies that an MxValue carrying a raw_value bytes payload round-trips.</summary>
[Fact]
public void MxValue_RoundTripsRawValueBytesPayload()
{
var original = new MxValue
{
DataType = MxDataType.Unknown,
VariantType = "VT_UNKNOWN",
RawDataType = 99,
RawDiagnostic = "uninterpreted COM variant",
RawValue = ByteString.CopyFrom(0x01, 0x02, 0xFE, 0xFF),
};
var parsed = MxValue.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxValue.KindOneofCase.RawValue, parsed.KindCase);
Assert.Equal(new byte[] { 0x01, 0x02, 0xFE, 0xFF }, parsed.RawValue.ToByteArray());
}
/// <summary>Verifies that an MxArray carrying a RawArray of byte blobs round-trips.</summary>
[Fact]
public void MxArray_RoundTripsRawArrayPayload()
{
var original = new MxArray
{
ElementDataType = MxDataType.Unknown,
VariantType = "VT_ARRAY|VT_UNKNOWN",
RawElementDataType = 99,
RawDiagnostic = "uninterpreted SAFEARRAY",
Dimensions = { 2 },
RawValues = new RawArray
{
Values =
{
ByteString.CopyFrom(0xAA, 0xBB),
ByteString.CopyFrom(0xCC, 0xDD),
},
},
};
var parsed = MxArray.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxArray.ValuesOneofCase.RawValues, parsed.ValuesCase);
Assert.Equal(2, parsed.RawValues.Values.Count);
}
/// <summary>Verifies that a BulkSubscribeReply with per-item SubscribeResults round-trips.</summary>
[Fact]
public void BulkSubscribeReply_RoundTripsSubscribeResults()
{
var original = new BulkSubscribeReply
{
Results =
{
new SubscribeResult
{
ServerHandle = 10,
TagAddress = "Provider!Tank01.Level",
ItemHandle = 21,
WasSuccessful = true,
},
new SubscribeResult
{
ServerHandle = 10,
TagAddress = "Provider!Bad.Tag",
WasSuccessful = false,
ErrorMessage = "item not found",
},
},
};
var parsed = BulkSubscribeReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(2, parsed.Results.Count);
Assert.True(parsed.Results[0].WasSuccessful);
Assert.False(parsed.Results[1].WasSuccessful);
}
/// <summary>Verifies that a bulk-subscribe command and its BulkSubscribeReply payload round-trip.</summary>
[Fact]
public void MxCommandReply_RoundTripsBulkSubscribePayload()
{
var original = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-bulk",
Kind = MxCommandKind.SubscribeBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
SubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult
{
ServerHandle = 5,
TagAddress = "Provider!Tank01.Level",
ItemHandle = 7,
WasSuccessful = true,
},
},
},
};
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommandReply.PayloadOneofCase.SubscribeBulk, parsed.PayloadCase);
Assert.Single(parsed.SubscribeBulk.Results);
}
/// <summary>Verifies that a WorkerEnvelope carrying a WorkerFault body round-trips.</summary>
[Fact]
public void WorkerEnvelope_RoundTripsWorkerFaultBody()
{
var original = new WorkerEnvelope
{
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
SessionId = "session-1",
Sequence = 11,
CorrelationId = "gateway-correlation-fault",
WorkerFault = new WorkerFault
{
Category = WorkerFaultCategory.MxaccessCommandFailed,
CommandMethod = "Register",
Hresult = unchecked((int)0x80004005),
ExceptionType = "System.Runtime.InteropServices.COMException",
DiagnosticMessage = "MXAccess COM call failed.",
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.ProtocolViolation },
},
};
var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerFault, parsed.BodyCase);
Assert.True(parsed.WorkerFault.HasHresult);
}
/// <summary>Verifies that a WorkerEnvelope carrying a WorkerHeartbeat body round-trips.</summary>
[Fact]
public void WorkerEnvelope_RoundTripsWorkerHeartbeatBody()
{
var activity = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 9, 0, 0, DateTimeKind.Utc));
var original = new WorkerEnvelope
{
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
SessionId = "session-1",
Sequence = 12,
CorrelationId = "gateway-correlation-heartbeat",
WorkerHeartbeat = new WorkerHeartbeat
{
WorkerProcessId = 4242,
State = WorkerState.Ready,
LastStaActivityTimestamp = activity,
PendingCommandCount = 3,
OutboundEventQueueDepth = 7,
LastEventSequence = 1234,
CurrentCommandCorrelationId = "in-flight-1",
},
};
var parsed = WorkerEnvelope.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHeartbeat, parsed.BodyCase);
Assert.Equal(WorkerState.Ready, parsed.WorkerHeartbeat.State);
}
/// <summary>Verifies that the Galaxy Repository service descriptor exposes its browse RPCs.</summary>
[Fact]
public void GalaxyRepositoryDescriptor_ContainsBrowseServiceMethods()
{
var service = Assert.Single(
GalaxyRepositoryReflection.Descriptor.Services,
descriptor => descriptor.Name == "GalaxyRepository");
Assert.Contains(service.Methods, method => method.Name == "TestConnection");
Assert.Contains(service.Methods, method => method.Name == "GetLastDeployTime");
Assert.Contains(service.Methods, method => method.Name == "DiscoverHierarchy");
Assert.Contains(service.Methods, method => method.Name == "WatchDeployEvents");
}
/// <summary>
/// Verifies that a DiscoverHierarchyRequest round-trips through every
/// <c>root</c> oneof arm and its proto wrapper-typed <c>max_depth</c> field.
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void DiscoverHierarchyRequest_RoundTripsRootOneofAndWrapperFields(int rootArm)
{
var original = new DiscoverHierarchyRequest
{
PageSize = 100,
PageToken = "page-2",
MaxDepth = 5,
CategoryIds = { 3, 9 },
TemplateChainContains = { "Analog", "Pump" },
TagNameGlob = "Tank*",
IncludeAttributes = true,
AlarmBearingOnly = true,
HistorizedOnly = false,
};
switch (rootArm)
{
case 0:
original.RootGobjectId = 4711;
break;
case 1:
original.RootTagName = "Tank01";
break;
default:
original.RootContainedPath = "Area1.Tank01";
break;
}
var parsed = DiscoverHierarchyRequest.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(original.RootCase, parsed.RootCase);
Assert.NotEqual(DiscoverHierarchyRequest.RootOneofCase.None, parsed.RootCase);
Assert.NotNull(parsed.MaxDepth);
Assert.Equal(5, parsed.MaxDepth!.Value);
Assert.True(parsed.HasIncludeAttributes);
Assert.True(parsed.IncludeAttributes);
}
/// <summary>
/// Verifies that a DiscoverHierarchyReply round-trips with nested
/// GalaxyObject and GalaxyAttribute graphs.
/// </summary>
[Fact]
public void DiscoverHierarchyReply_RoundTripsObjectAndAttributeGraph()
{
var original = new DiscoverHierarchyReply
{
NextPageToken = "page-3",
TotalObjectCount = 2,
Objects =
{
new GalaxyObject
{
GobjectId = 4711,
TagName = "Tank01",
ContainedName = "Tank01",
BrowseName = "Tank 01",
ParentGobjectId = 12,
IsArea = false,
CategoryId = 3,
HostedByGobjectId = 8,
TemplateChain = { "$AnalogDevice", "$Tank" },
Attributes =
{
new GalaxyAttribute
{
AttributeName = "Level",
FullTagReference = "Galaxy!Tank01.Level",
MxDataType = 3,
DataTypeName = "Float",
IsArray = false,
ArrayDimension = 0,
ArrayDimensionPresent = false,
MxAttributeCategory = 1,
SecurityClassification = 0,
IsHistorized = true,
IsAlarm = true,
},
},
},
new GalaxyObject
{
GobjectId = 12,
TagName = "Area1",
IsArea = true,
},
},
};
var parsed = DiscoverHierarchyReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(2, parsed.Objects.Count);
Assert.Single(parsed.Objects[0].Attributes);
Assert.True(parsed.Objects[0].Attributes[0].IsAlarm);
}
/// <summary>Verifies that a DeployEvent round-trips with its timestamp and counters.</summary>
[Fact]
public void DeployEvent_RoundTripsTimestampAndCounters()
{
var observed = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 30, 0, DateTimeKind.Utc));
var deploy = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 0, 0, DateTimeKind.Utc));
var original = new DeployEvent
{
Sequence = 17,
ObservedAt = observed,
TimeOfLastDeploy = deploy,
TimeOfLastDeployPresent = true,
ObjectCount = 240,
AttributeCount = 3600,
};
var parsed = DeployEvent.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.True(parsed.TimeOfLastDeployPresent);
Assert.Equal(deploy, parsed.TimeOfLastDeploy);
}
/// <summary>Verifies that GetLastDeployTimeReply and TestConnectionReply round-trip.</summary>
[Fact]
public void GalaxyConnectionReplies_RoundTrip()
{
var deploy = Timestamp.FromDateTime(new DateTime(2026, 5, 18, 8, 0, 0, DateTimeKind.Utc));
var lastDeploy = new GetLastDeployTimeReply
{
Present = true,
TimeOfLastDeploy = deploy,
};
var testConnection = new TestConnectionReply { Ok = true };
Assert.Equal(lastDeploy, GetLastDeployTimeReply.Parser.ParseFrom(lastDeploy.ToByteArray()));
Assert.Equal(testConnection, TestConnectionReply.Parser.ParseFrom(testConnection.ToByteArray()));
}
}