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
+16 -16
View File
@@ -7,7 +7,7 @@
| Review date | 2026-05-18 |
| Commit reviewed | `6c64030` |
| Status | Reviewed |
| Open findings | 7 |
| Open findings | 0 |
## Checklist coverage
@@ -20,7 +20,7 @@
| 5 | Security | Credential-sensitive fields are clearly commented; no secrets forced into loggable shapes. No issues found. |
| 6 | Performance & resource management | `DiscoverHierarchy` is paged; alarm-snapshot streams are server-streamed; no bloat issues. No issues found. |
| 7 | Design-document adherence | `.proto` files match design intent but `docs/Grpc.md` is stale (Contracts-001); worker vs public alarm-status shapes unreconciled in docs (Contracts-008). |
| 8 | Code organization & conventions | Package/file layout correct; `mxaccess_worker.proto` Protobuf item missing `ProtoRoot` (Contracts-003); stale class summary (Contracts-004). |
| 8 | Code organization & conventions | Package/file layout correct; stale class summary (Contracts-004). Contracts-003 (`mxaccess_worker.proto` Protobuf item missing `ProtoRoot`) was re-triaged as not-a-defect — the attribute is already present. |
| 9 | Testing coverage | Gateway/worker/alarm round-trips covered; Galaxy Repository protos and raw `MxArray` paths untested (Contracts-007). |
| 10 | Documentation & comments | Proto comments accurate and domain-rich; one stale class summary (Contracts-004). |
@@ -33,13 +33,13 @@
| Severity | Low |
| Category | Design-document adherence |
| Location | `docs/Grpc.md:13` (and `:3`, `:32`, `:39`) |
| Status | Open |
| Status | Resolved |
**Description:** `mxaccess_gateway.proto` now declares six RPCs on `MxAccessGateway` (`OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, `QueryActiveAlarms`). `docs/Grpc.md` still describes "the four `MxAccessGateway` RPCs" in its type table and omits `AcknowledgeAlarm`/`QueryActiveAlarms` from the Validation Rules table. CLAUDE.md requires docs to change in the same commit as the contract; the alarm RPC commits left this doc stale and misleading about the public surface.
**Recommendation:** Update `docs/Grpc.md` to enumerate all six RPCs and add `AcknowledgeAlarm`/`QueryActiveAlarms` to the type/handler and validation tables, or explicitly cross-reference `AlarmClientDiscovery.md`.
**Resolution:** _(open)_
**Resolution:** _(2026-05-18)_ Confirmed against `mxaccess_gateway.proto` — six RPCs declared, doc said "four". Updated `docs/Grpc.md`: the collaborator table now says "six `MxAccessGateway` RPCs", the RPC Handlers intro enumerates all six, added dedicated `AcknowledgeAlarm` and `QueryActiveAlarms` handler subsections (noting the alarm surface routes through `IAlarmRpcDispatcher` and is validated inline rather than via `MxAccessGrpcRequestValidator`, with a cross-reference to `AlarmClientDiscovery.md`), and added both alarm RPCs to the Validation Rules table.
### Contracts-002
@@ -63,13 +63,13 @@
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/MxGateway.Contracts/MxGateway.Contracts.csproj:10` |
| Status | Open |
| Status | Won't Fix (re-triaged — not a defect) |
**Description:** The `<Protobuf>` item for `mxaccess_worker.proto` omits `ProtoRoot="Protos"`, while the items for `mxaccess_gateway.proto` (line 9) and `galaxy_repository.proto` (line 11) both set it. `mxaccess_worker.proto` does `import "mxaccess_gateway.proto"`, which resolves only because Grpc.Tools adds the importing file's own directory to the proto path. The inconsistency is fragile — tooling changes to ProtoRoot handling could break import resolution.
**Recommendation:** Add `ProtoRoot="Protos"` to the `mxaccess_worker.proto` `<Protobuf>` item so all three entries are consistent.
**Resolution:** _(open)_
**Resolution:** _(2026-05-18)_ Re-triaged as not-a-defect: the finding's premise is factually wrong. Line 10 of `MxGateway.Contracts.csproj` already carries `ProtoRoot="Protos"` — all three `<Protobuf>` items are already consistent. `git show 6c64030:src/MxGateway.Contracts/MxGateway.Contracts.csproj` (the reviewed commit) confirms the attribute was present at review time too; the csproj has not been touched since `133c830`. No code change made. Status set to Won't Fix because there is nothing to fix.
### Contracts-004
@@ -78,13 +78,13 @@
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/MxGateway.Contracts/GatewayContractInfo.cs:3-6` |
| Status | Open |
| Status | Resolved |
**Description:** The XML summary says the class exposes version metadata "before generated protobuf contracts are introduced." Generated protobuf contracts have long been introduced and are consumed across the solution. The comment is stale; the class now holds the authoritative `GatewayProtocolVersion`/`WorkerProtocolVersion` advertised in `OpenSessionReply` and used to validate `WorkerEnvelope` framing.
**Recommendation:** Reword the summary to describe the current purpose — version constants advertised in `OpenSessionReply` and used to validate `WorkerEnvelope` protocol framing.
**Resolution:** _(open)_
**Resolution:** _(2026-05-18)_ Confirmed stale — the class is consumed by `GatewayApplication`/`OpenSessionReply` and `WorkerEnvelope` framing checks across the solution. Reworded the XML summary on `GatewayContractInfo` to describe the actual current purpose: `GatewayProtocolVersion` is advertised to clients in `OpenSessionReply`, and `WorkerProtocolVersion` validates `WorkerEnvelope` protocol framing on the gateway↔worker pipe.
### Contracts-005
@@ -93,13 +93,13 @@
| Severity | Low |
| Category | mxaccessgw conventions |
| Location | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`, `src/MxGateway.Contracts/Protos/mxaccess_worker.proto` |
| Status | Open |
| Status | Resolved |
**Description:** The ProtobufStyleGuide mandates reserving removed field numbers / enum values. Evolution to date has been purely additive, so this is not a current violation — but none of the `.proto` files contain any `reserved` declarations, leaving no in-file guardrail for the first removal. This is a latent maintainability gap.
**Recommendation:** When any field or enum value is eventually removed, add a `reserved` range/name in the same change. Consider a short comment block in each message documenting the policy so future editors apply `reserved` rather than reusing tags.
**Resolution:** _(open)_
**Resolution:** _(2026-05-18)_ Confirmed: no field or enum value has ever been removed, so adding `reserved` ranges now would be incorrect (there are no retired tags to reserve, and inventing ranges for never-used numbers would itself violate the contract). Took the finding's least-invasive option — added a short wire-compatibility policy comment block at the top of all three `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`) stating the additive-only rule and instructing future editors to add a `reserved` range + name in the same change as any removal. Comment-only, no wire-format or generated-type change. The `reserved` declarations themselves remain correctly deferred to the first actual removal.
### Contracts-006
@@ -108,13 +108,13 @@
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto:647` |
| Status | Open |
| Status | Resolved |
**Description:** `MxStatusProxy.success` is declared `int32 success = 1` with no comment. The name reads like a boolean flag but the type is a 32-bit integer (mirroring MXAccess `MXSTATUS_PROXY`, which stores a numeric success/HResult-like value). Without a comment a client author can reasonably misinterpret the field (treat non-1 as failure, or expect only 0/1).
**Recommendation:** Add a comment clarifying the semantic — what range of values it carries and how 0 vs non-zero map to MXAccess status — per the style guide rule to comment fields carrying raw MXAccess status detail.
**Resolution:** _(open)_
**Resolution:** _(2026-05-18)_ Confirmed: `int32 success = 1` had no comment. Cross-checked against the worker `MxStatusProxyConverter`, which reads the COM struct's `success` field verbatim (a 16-bit signed value) without reinterpretation, and against the MXAccess analysis (`MXAccess-Public-API.md`: `MxStatus`/`MXSTATUS_PROXY` are identical structs with a `short success` member). Added a field comment to `MxStatusProxy.success` stating it mirrors the COM struct's numeric `success` member (NOT a boolean), is carried verbatim for diagnostics, and that clients should branch on `category` (`MX_STATUS_CATEGORY_OK` marks success) — deliberately avoiding an over-specified 0-vs-1 claim, since the gateway never maps `success` to an outcome and `category` is the authoritative field. Comment-only change.
### Contracts-007
@@ -123,13 +123,13 @@
| Severity | Low |
| Category | Testing coverage |
| Location | `src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs` |
| Status | Open |
| Status | Resolved |
**Description:** `ProtobufContractRoundTripTests` covers gateway command/reply/event, alarm transition, alarm ack request/reply, active-alarm snapshot, and the worker envelope. It has no coverage for: (a) any `galaxy_repository.proto` message (`DiscoverHierarchy*`, `GalaxyObject`, `GalaxyAttribute`, `DeployEvent`, the `root` oneof, wrapper-typed fields); (b) `BulkSubscribeReply`/`SubscribeResult` and the bulk command kinds; (c) `MxValue`/`MxArray` `raw_value`/`RawArray` (`bytes`) paths and the `WorkerFault`/`WorkerHeartbeat` IPC bodies.
**Recommendation:** Add round-trip tests for the Galaxy Repository messages (including the `root` oneof and proto wrapper fields), the bulk-subscribe reply, and the remaining `WorkerEnvelope` body cases.
**Resolution:** _(open)_
**Resolution:** _(2026-05-18)_ Confirmed the listed gaps and added round-trip tests to `ProtobufContractRoundTripTests` covering all three areas: (a) Galaxy Repository — `GalaxyRepositoryDescriptor_ContainsBrowseServiceMethods`, `DiscoverHierarchyRequest_RoundTripsRootOneofAndWrapperFields` (a `[Theory]` exercising all three `root` oneof arms plus the `Int32Value` wrapper `max_depth`), `DiscoverHierarchyReply_RoundTripsObjectAndAttributeGraph`, `DeployEvent_RoundTripsTimestampAndCounters`, `GalaxyConnectionReplies_RoundTrip`; (b) `BulkSubscribeReply_RoundTripsSubscribeResults` and `MxCommandReply_RoundTripsBulkSubscribePayload` (bulk-subscribe command kind + payload case); (c) `MxValue_RoundTripsRawValueBytesPayload`, `MxArray_RoundTripsRawArrayPayload`, `WorkerEnvelope_RoundTripsWorkerFaultBody`, `WorkerEnvelope_RoundTripsWorkerHeartbeatBody`. All new tests pass; the full `ProtobufContractRoundTripTests` class is 27 tests green.
### Contracts-008
@@ -138,10 +138,10 @@
| Severity | Low |
| Category | Design-document adherence |
| Location | `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto:451-459`, `:627-636` |
| Status | Open |
| Status | Resolved |
**Description:** The worker-side `AcknowledgeAlarmReplyPayload` carries the alarm-ack outcome as `int32 native_status`, while the public `AcknowledgeAlarmReply` carries it as `MxStatusProxy status` plus `optional int32 hresult`. The comment explains the worker echoes `native_status` into `AcknowledgeAlarmReply.hresult`, but the two outcome shapes (raw `int32` vs structured `MxStatusProxy`) are not reconciled in `docs/Contracts.md` / `AlarmClientDiscovery.md`. A reader cannot tell whether `MxStatusProxy status` is always populated or only on COM-layer failure.
**Recommendation:** Document in `docs/Contracts.md` (or `AlarmClientDiscovery.md`) how the worker `native_status` maps onto the public reply's `status`/`hresult` pair so client authors know which field is authoritative.
**Resolution:** _(open)_
**Resolution:** _(2026-05-18)_ Verified against `WorkerAlarmRpcDispatcher.AcknowledgeAsync`. The asymmetry is larger than the finding implies: the dispatcher copies the worker `MxCommandReply.hresult` into `AcknowledgeAlarmReply.hresult` but **never** assigns `AcknowledgeAlarmReply.status` — the `MxStatusProxy status` field is left UNSET on every reply. The proto comment on `status` ("Native MxAccess status describing the outcome of the ack") was therefore actively misleading. Fixed: (1) reworded the `mxaccess_gateway.proto` comments on `AcknowledgeAlarmReply.hresult` (now identifies it as the authoritative native-return-code field) and `AcknowledgeAlarmReply.status` (now states it is reserved/unset and clients must not depend on it); (2) extended `docs/AlarmClientDiscovery.md` section 4 with a "Worker `native_status` → public `AcknowledgeAlarmReply` mapping" subsection spelling out that `hresult` is authoritative (`0` = success) and `status` is always unset, and that clients should branch on `protocol_status` then `hresult`, never `status`.
+22
View File
@@ -776,6 +776,28 @@ case, to distinguish the two acks. `WorkerAlarmRpcDispatcher` reads only
the top-level `hresult`/`protocol_status`, so it handles both arms
without unpacking the payload.
**Worker `native_status` → public `AcknowledgeAlarmReply` mapping.** The
worker carries the ack outcome as a single `int32`
(`AcknowledgeAlarmReplyPayload.native_status`, the `AlarmAckByName` /
`AlarmAckByGUID` return code; `0` = success), also mirrored into the
worker `MxCommandReply.hresult`. The public `AcknowledgeAlarmReply` has
two outcome-shaped fields, but only one is populated:
- `AcknowledgeAlarmReply.hresult` — `WorkerAlarmRpcDispatcher` copies the
worker's `MxCommandReply.hresult` (the native return code) into this
field. **This is the authoritative ack-outcome field**; `0` means the
ack succeeded. It is absent only when the worker reply omitted the
value, which is a protocol violation surfaced in `protocol_status`.
- `AcknowledgeAlarmReply.status` (`MxStatusProxy`) — the worker by-name /
by-GUID ack path produces only the `int32` return code, never a
populated `MXSTATUS_PROXY` struct, so `WorkerAlarmRpcDispatcher` leaves
this field **unset on every reply**. It is reserved for a future
structured view of the ack outcome. Clients must not depend on it.
Client authors should therefore branch on `protocol_status` first (for
transport/session-level failures) and then on `hresult` (`0` = ack
accepted by MXAccess) — never on `status`.
### 5. STA / threading — production fix needed
The wnwrap COM is `ThreadingModel=Apartment`. The consumer's
+12 -2
View File
@@ -10,7 +10,7 @@ The layer is composed of four collaborators:
| Type | Lifetime | Role |
|------|----------|------|
| `MxAccessGatewayService` | scoped (gRPC) | Implements the four `MxAccessGateway` RPCs, performs exception mapping. |
| `MxAccessGatewayService` | scoped (gRPC) | Implements the six `MxAccessGateway` RPCs, performs exception mapping. |
| `MxAccessGrpcRequestValidator` | singleton | Rejects malformed requests before any session work runs. |
| `MxAccessGrpcMapper` | singleton | Converts public proto types to internal `WorkerCommand`/`WorkerEvent` types and back. |
| `IEventStreamService` (`EventStreamService`) | singleton | Owns the event stream pipeline, including bounded queue and backpressure handling. |
@@ -29,7 +29,7 @@ A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It
## RPC Handlers
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — six in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, and `QueryActiveAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
Public gRPC send and receive message sizes are configured from
`MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use
@@ -86,6 +86,14 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
`StreamEvents` is a server-streaming RPC. The handler delegates the full pipeline to `IEventStreamService` and just forwards each `MxEvent` onto the response stream. Keeping the channel and producer/consumer machinery out of the handler means cancellation, exception mapping, and metric bookkeeping live in one place.
### `AcknowledgeAlarm`
`AcknowledgeAlarm` is a unary RPC that acknowledges a single alarm. The handler validates `session_id` and `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`, because the alarm surface routes through `IAlarmRpcDispatcher` rather than the generic `Invoke` path), resolves the session, then delegates to the registered `IAlarmRpcDispatcher`. The production `WorkerAlarmRpcDispatcher` routes the ack over the worker IPC by GUID (`AcknowledgeAlarmCommand`) when the reference parses as a canonical GUID, or by `Provider!Group.Tag` reference (`AcknowledgeAlarmByNameCommand`) otherwise. The handler-level RPC behaviour and the alarm contract itself are documented in [Alarm Client Discovery](./AlarmClientDiscovery.md).
### `QueryActiveAlarms`
`QueryActiveAlarms` is a server-streaming RPC that returns an `ActiveAlarmSnapshot` per currently active alarm. The handler validates `session_id` inline, resolves the session, and delegates to `IAlarmRpcDispatcher`; `WorkerAlarmRpcDispatcher` issues a `QueryActiveAlarmsCommand` over the worker IPC and streams each snapshot from the worker reply.
## Validation Rules
`MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free.
@@ -96,6 +104,8 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
| `CloseSession` | `session_id` must be non-empty. | `InvalidArgument` |
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
| `AcknowledgeAlarm` | `session_id` and `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
| `QueryActiveAlarms` | `session_id` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error:
@@ -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()));
}
}