Files
mxaccessgw/archreview/remediation/50-clients.md
T
Joseph Doherty 59856b8c63 docs(archreview): add architecture review + per-domain remediation designs and tracker
Adds the 2026-07-08 architecture review (00-overall + six domain reports)
and a remediation/ tree: one design+implementation doc per domain covering
every finding, plus 00-tracking.md as the master progress tracker.

- 153 findings with stable IDs (GWC/WRK/IPC/SEC/CLI/TST), each with
  design rationale, implementation steps, tests, docs, and verification.
- Tracker rolls findings up by severity and P0/P1/P2 roadmap tier, records
  cross-cutting clusters and per-finding status (all Not started).
- Planning docs only; no source changes.
2026-07-09 00:39:00 -04:00

624 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Language Clients — Remediation Design & Implementation
Source review: [50-clients.md](../50-clients.md) · Generated: 2026-07-09
This document turns the Language Clients review into buildable remediation entries for all five clients (`clients/dotnet`, `clients/go`, `clients/java`, `clients/python`, `clients/rust`). Every finding carries the target language explicitly. The JDK 17 retarget is verified correct in build config (J1 in the source report is a no-defect verification and gets no remediation row); its only fallout is stale docs (CLI-12). Two operational constraints from prior work carry into these fixes: the Java client cannot build or test on the macOS tree (no local JRE — build/test on windev), and the Java Gradle build regenerates a tracked ~64k-line `MxaccessGateway.java` whose spurious protobuf-version churn must be reverted (`git checkout`) whenever no `.proto` changed. Python regen must pin `grpcio-tools` to the baseline (grpcio 1.80.0 / protobuf 6.31.1); the Rust `build.rs` proto path and `--no-verify` packaging are themselves a finding (CLI-02).
## Finding index
| ID | Sev | Title | Roadmap | Effort | Depends on | Files |
|----|-----|-------|---------|--------|-----------|-------|
| CLI-01 | High | Go `Session.Events()` silently closes stream on 16-slot overflow | P0 | M | — | clients/go/mxgateway/session.go |
| CLI-02 | High | Rust crate unbuildable outside the repo (`build.rs` path + `--no-verify`) | P1 | M | — | clients/rust/build.rs, clients/rust/Cargo.toml, scripts/pack-clients.ps1 |
| CLI-03 | High | Rust `invoke` never validates HRESULT / MXSTATUS_PROXY | P0 | M | — | clients/rust/src/error.rs, clients/rust/src/client.rs |
| CLI-04 | High | Typed-command parity gap across all five clients (WriteSecured/AuthenticateUser/AdviseSupervisory/buffered) | P2 | L | CLI-15 | clients/{dotnet,go,java,python,rust} session surfaces |
| CLI-05 | Medium | .NET session cannot be re-attached to an existing session id | — | S | — | clients/dotnet/.../MxGatewaySession.cs, MxGatewayClient.cs |
| CLI-06 | Medium | .NET `DisposeAsync` blocks/throws on unreachable gateway | — | S | — | clients/dotnet/.../MxGatewaySession.cs |
| CLI-07 | Medium | .NET retry budget self-defeats on `DeadlineExceeded` | — | S | — | clients/dotnet/.../MxGatewayClient.cs, MxGatewayClientRetryPolicy.cs |
| CLI-08 | Medium | .NET/Go/Java treat any nonzero HRESULT as failure (should be `< 0`) | — | S | CLI-03 | clients/dotnet/.../MxCommandReplyExtensions.cs, clients/go/mxgateway/errors.go, clients/java/.../MxGatewayErrors.java |
| CLI-09 | Medium | Go has no typed auth-error mapping (Unauthenticated vs PermissionDenied) | — | M | — | clients/go/mxgateway/errors.go, client.go |
| CLI-10 | Medium | Go uses deprecated `grpc.DialContext` + `grpc.WithBlock()` | — | M | — | clients/go/mxgateway/client.go |
| CLI-11 | Medium | Go CLI cannot opt into strict TLS validation | — | S | — | clients/go/cmd/mxgw-go/main.go |
| CLI-12 | Medium | Java docs still say "Java 21" after the JDK 17 retarget | P2 | S | — | clients/java/README.md, clients/java/JavaClientDesign.md, docs/ClientPackaging.md |
| CLI-13 | Medium | Java event-stream buffer hardcoded 16, cancel-on-overflow | — | M | — | clients/java/.../MxGatewayClient.java, MxEventStream.java |
| CLI-14 | Medium | Python default TLS is blocking TOFU pin with silent `localhost` SNI | — | S | — | clients/python/.../options.py, clients/python/README |
| CLI-15 | Medium | No client handles `ReplayGap` or offers a reconnect helper | P2 | M | — | clients/{dotnet,go,java,python,rust} + READMEs |
| CLI-16 | Medium | `docs/ClientPackaging.md` drifted from Python naming and .NET `.slnx` | P2 | S | CLI-12 | docs/ClientPackaging.md, docs/ClientLibrariesDesign.md |
| CLI-17 | Medium | TLS default posture inconsistent across the five clients | — | M | CLI-14 | clients/{dotnet,go,java,python,rust} TLS paths |
| CLI-18 | Low | .NET csproj records no `<Version>` | P2 | S | — | clients/dotnet/.../ZB.MOM.WW.MxGateway.Client.csproj |
| CLI-19 | Low | .NET duplicate `InternalsVisibleTo` (AssemblyInfo + csproj) | — | S | — | clients/dotnet/.../Properties/AssemblyInfo.cs, csproj |
| CLI-20 | Low | .NET `--tls` without CA installs accept-all callback | — | S | CLI-17 | clients/dotnet/.../MxGatewayClient.cs |
| CLI-21 | Low | Go `ClientVersion = "0.1.0-dev"` stale vs tagged releases | P2 | S | — | clients/go/mxgateway/version.go, scripts/tag-go-module.ps1 |
| CLI-22 | Low | Go `newCorrelationID` swallows `crypto/rand` error → empty id | — | S | — | clients/go/mxgateway/session.go |
| CLI-23 | Low | Go nil-vs-empty bulk short-circuit asymmetry | — | S | — | clients/go/mxgateway/session.go |
| CLI-24 | Low | Java `MxEventStream` single-consumer constraint undocumented | — | S | — | clients/java/.../MxEventStream.java |
| CLI-25 | Low | Java `close()` does not await channel termination | — | S | — | clients/java/.../MxGatewayClient.java |
| CLI-26 | Low | Python `version.py` (0.1.0) ≠ `pyproject.toml` (0.1.2) | P2 | S | — | clients/python/.../version.py, pyproject.toml |
| CLI-27 | Low | Python `Session.close()` not concurrency-safe; synthesizes reply | — | S | — | clients/python/.../session.py |
| CLI-28 | Low | Python circular-import workaround at end of `session.py` | — | S | — | clients/python/.../session.py |
| CLI-29 | Low | Rust `CLIENT_VERSION` (0.1.0-dev) ≠ `Cargo.toml` (0.1.2) | P2 | S | — | clients/rust/src/version.rs |
| CLI-30 | Low | Rust has no `unregister` typed helper | — | S | CLI-04 | clients/rust/src/session.rs |
| CLI-31 | Low | Rust CLI is a single 2,699-line `main.rs` | — | M | — | clients/rust/crates/mxgw-cli/src/main.rs |
| CLI-32 | Low | Client-side bulk caps differ (.NET/Java unbounded) | — | S | — | clients/dotnet, clients/java session surfaces |
| CLI-33 | Low | Per-language event backpressure semantics undocumented | — | S | CLI-01, CLI-13 | docs/ClientBehaviorFixtures.md |
| CLI-34 | Low | Python `build/`/`.pytest_cache/` present on disk (untracked) | — | S | — | clients/python/.gitignore |
---
## CLI-01 — Go `Session.Events()` silently closes stream on 16-slot overflow `High` · `P0`
**Finding.** In the Go client, `subscribeEventsAfter` (`clients/go/mxgateway/session.go:700-742`) spawns a goroutine that pushes each event through `sendEventResult` into a 16-slot channel. When the buffer is full and `cancelWhenBufferFull` is set (the `Events()`/`EventsAfter()` path), the `default:` arm cancels the stream and returns `false` with **no terminal error enqueued** (`session.go:751-768`); the goroutine then `close(results)` via `defer` (`:714`). The doc comment promises the stream runs "until … a terminal error is sent" (`:675-677`) but never mentions this drop.
**Impact.** A consumer that stalls for more than 16 queued events sees a closed channel indistinguishable from graceful server end. Events are lost with no signal — silent data loss, the single most serious runtime defect in the client tree. This is roadmap P0 item 6.
**Design.** Reserve one slot for a terminal `EventResult` so overflow is always observable. Add an exported sentinel `ErrSlowConsumer` and, on the overflow branch, cancel the stream and attempt a **non-blocking** send of `EventResult{Err: ...wrapping ErrSlowConsumer}`; the reserved capacity guarantees that final send succeeds even when the 16 data slots are full. Keep the blocking `SubscribeEvents` path (which rides gRPC flow control) unchanged. Rejected alternative: dropping the buffered-cancel variant entirely — it is public API and some callers depend on non-blocking semantics; a loud terminal error preserves the contract while removing the silent-loss failure mode.
**Implementation.**
- `clients/go/mxgateway/errors.go`: add `var ErrSlowConsumer = errors.New("mxgateway: event consumer fell behind; stream terminated")`.
- `clients/go/mxgateway/session.go`: size the channel to `16+1` (or track a reserved terminal slot); in `sendEventResult`'s `default:` arm, call `cancel()` then a non-blocking `select { case results <- EventResult{Err: &GatewayError{Op: "stream events", Err: ErrSlowConsumer}}: default: }` before returning `false`. Update the `Events`/`EventsAfter` doc comments to state the slow-consumer termination contract.
- Tests: extend the session event tests (`clients/go/mxgateway/*_test.go`, the fixture-driven event suite) with a slow-consumer case asserting the channel yields a final `EventResult` whose `errors.Is(res.Err, ErrSlowConsumer)` is true before close.
- Docs: record the Go slow-consumer terminal-error behavior in `docs/ClientBehaviorFixtures.md` (see CLI-33).
**Verification.** From `clients/go`: `gofmt -l .`, `go build ./...`, `go test ./...`.
---
## CLI-02 — Rust crate unbuildable outside the repository `High` · `P1`
**Finding.** The Rust `build.rs` resolves protos two directories above the crate — `repo_root.join("src/ZB.MOM.WW.MxGateway.Contracts/Protos")` with the hard assumption "clients/rust must live two levels below the repository root" (`clients/rust/build.rs:8-16`) — and `src/generated.rs` is only `tonic::include_proto!` of that build output. `Cargo.toml` does not vendor the `.proto` files. The packaging script hides the defect with `cargo package --no-verify` / `cargo publish --no-verify` (`scripts/pack-clients.ps1:190-211`). Any consumer of the published `zb-mom-ww-mxgateway-client 0.1.2` fails in `build.rs` on first `cargo build`.
**Impact.** The Gitea-published crate is dead on arrival for every external consumer — the most serious packaging finding. This is roadmap P1 item 11.
**Design.** Vendor the three protos into the crate and make `build.rs` prefer the in-repo source but fall back to the vendored copy, then drop `--no-verify` so `cargo package` proves standalone buildability. Copy `mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto` into `clients/rust/protos/` and list that dir in `Cargo.toml`'s `include`. In `build.rs`, probe the repo path first (keeps in-repo edits live) and fall back to `CARGO_MANIFEST_DIR/protos` when the repo path is absent. The vendored copies are build inputs, not the canonical source (which remains in Contracts) — add a note so they are refreshed on `.proto` change. Rejected alternative: publishing pre-generated `.rs` — tonic's generated code is tied to the tonic/prost version and would rot; regenerating from vendored protos at build time is the idiomatic Rust approach.
**Implementation.**
- Add `clients/rust/protos/{mxaccess_gateway,mxaccess_worker,galaxy_repository}.proto` (copies).
- `clients/rust/build.rs`: `let proto_root = if repo_proto_root.exists() { repo_proto_root } else { manifest_dir.join("protos") };` and compile from whichever exists; keep the descriptor-set output.
- `clients/rust/Cargo.toml`: add `include = ["src/**/*.rs", "protos/*.proto", "build.rs", ...]` so the protos ship in the package.
- `scripts/pack-clients.ps1:190-211`: remove `--no-verify` from the Rust `cargo package`/`cargo publish` invocations so verification runs.
- Optional: a small `build.rs` staleness note or a repo script that copies Contracts protos → `clients/rust/protos/` to keep them in sync (tie into the codegen-freshness theme of report 30/60).
- Docs: update the Rust section of `docs/ClientPackaging.md` and `clients/rust/README.md` to describe the vendored-proto layout.
**Verification.** From `clients/rust`: `cargo fmt`, `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings`, and crucially `cargo package` (no `--no-verify`) to prove the crate builds from the packaged tarball.
---
## CLI-03 — Rust `invoke` never validates HRESULT / MXSTATUS_PROXY `High` · `P0`
**Finding.** In the Rust client, `ensure_command_success` checks only `protocol_status.code == Ok` (`clients/rust/src/error.rs:214-226`) and is the sole reply check on the `invoke` path (`clients/rust/src/client.rs:177-179`). It never inspects `hresult` or the `MXSTATUS_PROXY` array. Every other client performs a second MXAccess-level check (`MxCommandReplyExtensions.cs:27-41`, `errors.go:117-130`, `MxGatewayErrors.java`, `errors.py:122-148`).
**Impact.** A reply with an OK protocol envelope but failing per-item MXAccess statuses reads as success in Rust — a `session.write(...)` can report success while MXAccess rejected the write. There is also no distinct MXAccess error variant to catch. Violates MXAccess-parity-is-the-contract. Roadmap P0 item 6.
**Design.** Add an `ensure_mxaccess_success` pass mirroring the other clients and an `Error::MxAccess` variant carrying the reply, and call it after `ensure_command_success` on the typed command paths. Use the **correct COM semantics — `hresult < 0`** (see CLI-08), matching Python, rather than `!= 0`; then any per-item status where success is false raises. Keep the raw `invoke` escape hatch un-validated (callers opting out of typed helpers accept raw replies), but apply the check in the typed `session.write`/command helpers. This is co-designed with CLI-08 (HRESULT semantics) — Rust should land on `< 0` from the start rather than repeat the `!= 0` mistake.
**Implementation.**
- `clients/rust/src/error.rs`: add `Error::MxAccess(Box<MxAccessError>)` (boxed, consistent with the existing boxed `tonic::Status`/`CommandError` pattern) with a `thiserror` message summarizing hresult + status entries and credential-safe formatting; add `pub fn ensure_mxaccess_success(reply: MxCommandReply) -> Result<MxCommandReply, Error>` checking `hresult < 0` and each `MxStatusProxy.success`.
- `clients/rust/src/client.rs` / `session.rs`: invoke `ensure_mxaccess_success` after `ensure_command_success` in the typed write/command wrappers.
- Tests: add a Rust unit/fixture test asserting a reply with `protocol_status = Ok` but a failing status entry (and one with a negative hresult) yields `Error::MxAccess`; reuse the shared behavior fixtures if they carry such a case.
- Docs: note the new `Error::MxAccess` variant in `clients/rust/README.md`.
**Verification.** From `clients/rust`: `cargo fmt`, `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings`.
---
## CLI-04 — Typed-command parity gap across all five clients `High` · `P2`
**Finding.** No client (`.NET`, `Go`, `Java`, `Python`, `Rust`) exposes typed session helpers for single-item `WriteSecured`/`WriteSecured2`, `AuthenticateUser`, `ArchestrAUserToId`, `AdviseSupervisory`, `AddBufferedItem`, `SetBufferedUpdateInterval`, `Suspend`, or `Activate`, though the wire contract defines all of them (`src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto:150-160`) and `gateway.md` documents `AdviseSupervisory` as a precondition for user-attributed plain writes. Only the .NET, Go, and Python CLIs offer `advise-supervisory` via hand-built raw commands (`clients/go/cmd/mxgw-go/main.go:364-391`, `clients/dotnet/.../MxGatewayClientCli.cs:456-470`, `clients/python/.../commands.py:287-291`); Java and Rust CLIs lack even that.
**Impact.** The secured-write parity path (AuthenticateUser → AdviseSupervisory → WriteSecured) and the buffered-event family (`OnBufferedDataChange`) are reachable only through raw `Invoke` — the single most important MXAccess parity surface is untyped in every language. Roadmap P2 item 13.
**Design.** Add the missing typed session helpers to all five clients, phased by parity value: (1) `adviseSupervisory`, single-item `writeSecured`/`writeSecured2`, `authenticateUser`, `archestrAUserToId`; (2) buffered family `addBufferedItem`/`setBufferedUpdateInterval` and `suspend`/`activate`. Each helper wraps the existing raw-`Invoke` machinery already used by the bulk variants, so no new wire surface is needed — the contract already carries the command kinds (`mxaccess_gateway.proto:150-160`). Follow each language's existing typed-helper idiom (e.g. `.NET` `InvokeCommandAsync` in `MxGatewaySession.cs`, Go `invokeCommand`, Rust `session.rs` wrappers, Java stub methods, Python async methods). Preserve MXAccess parity semantics exactly — do not "fix" `WriteSecured` requiring a prior authenticate/advise; surface the native failure. Credentials passed to `authenticateUser`/`writeSecured` must route through each client's existing secret-redaction seam (`MxGatewaySecrets.java`, Rust `error.rs` scrubbing, Go/Python redaction) so they never reach logs or error messages. Depends on CLI-15 (both touch the same session surfaces and READMEs — land as a coordinated session-surface pass).
**Implementation.**
- `.NET`: add `WriteSecuredAsync`/`AuthenticateUserAsync`/`AdviseSupervisoryAsync`/etc. to `MxGatewaySession.cs`, mirroring the bulk-helper pattern; extend `MxGatewayClientCli.cs`.
- `Go`: add methods to `session.go` alongside the bulk helpers; promote the CLI's hand-built `advise-supervisory` command into a typed method.
- `Java`: add methods to `MxGatewaySession.java` and a CLI subcommand (`zb-mom-ww-mxgateway-cli`).
- `Python`: add async methods to `session.py` and CLI commands in `commands.py`.
- `Rust`: add methods to `session.rs` (see CLI-30 for `unregister` symmetry) and subcommands to `mxgw-cli`.
- Tests: add per-client fixture/unit coverage for `writeSecured` (parity: fails without prior authenticate), `authenticateUser`, and `adviseSupervisory`; add these to the cross-language smoke matrix.
- Docs: update each client README's capability list, the parity matrix in `docs/ClientLibrariesDesign.md`, and `docs/CrossLanguageSmokeMatrix.md`.
**Verification.** Per language, the CLAUDE.md per-area commands: `.NET` `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` + tests; `Go` `go build/test ./...`; `Rust` `cargo check/test/clippy`; `Python` `python -m pytest`; `Java` `gradle test` **on windev** (no local JRE on the Mac — revert the regenerated `MxaccessGateway.java` churn afterward, since no `.proto` changed).
---
## CLI-05 — .NET session cannot be re-attached to an existing session id `Medium` · `—`
**Finding.** In the .NET client, `MxGatewaySession`'s constructor is `internal` (`clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs:19`) and no public factory exists, unlike Go's `NewSessionForID` (`clients/go/mxgateway/session.go:70`), Java's `forSessionId`, Python's ctor, or Rust's `client.session()`.
**Impact.** After a client restart, the gateway's `DetachGraceSeconds`/replay features are usable from .NET only through the raw stub — all typed helpers are lost on reconnect. Parity gap; .NET is the only client that cannot re-attach.
**Design.** Add a public factory `MxGatewayClient.AttachSession(string sessionId)` returning an `MxGatewaySession` whose `OpenSessionReply` is synthesized to carry the given `SessionId` (no server round-trip; the session already exists). Keep the `internal` ctor for the open path. This aligns .NET with the other four clients and unblocks CLI-15's reconnect helper. Alternative rejected: making the ctor public — the factory name documents intent (re-attach vs open) and keeps `OpenSessionReply` construction internal.
**Implementation.**
- `MxGatewayClient.cs`: add `public MxGatewaySession AttachSession(string sessionId)` building a minimal `OpenSessionReply { SessionId = sessionId }` and calling the internal ctor.
- Tests: add a case in the .NET session test project asserting `AttachSession(id).SessionId == id` and that a subsequent `StreamEventsAsync(afterWorkerSequence)` targets that id.
- Docs: note re-attach in `clients/dotnet/README.md` and flip the parity-matrix cell in `docs/ClientLibrariesDesign.md`.
**Verification.** `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` and `dotnet test` the client test project.
---
## CLI-06 — .NET `DisposeAsync` blocks/throws on unreachable gateway `Medium` · `—`
**Finding.** In the .NET client, `MxGatewaySession.DisposeAsync` calls `CloseAsync()` with no cancellation token or timeout (`clients/dotnet/.../MxGatewaySession.cs:881-885`). When the gateway is unreachable, `await using` disposal blocks for the full retry-pipeline budget and then throws out of disposal, masking the original exception.
**Impact.** `await using` on a session whose gateway just died hangs and then surfaces a disposal exception instead of the caller's real error — a poor failure mode on the common teardown path.
**Design.** Time-bound and swallow close failures in the disposal path only. Wrap the `CloseAsync` call in a short linked-timeout CTS and catch (`OperationCanceledException`/`RpcException`/`MxGatewayException`), since throwing from `DisposeAsync` is an anti-pattern that masks in-flight exceptions. Explicit `CloseAsync()` remains fully surfacing for callers who want to observe close failures. Keep the `_closeLock.Dispose()` unconditional.
**Implementation.**
- `MxGatewaySession.cs` `DisposeAsync`: `using var cts = new CancellationTokenSource(disposeCloseTimeout);` (a small fixed bound, e.g. 2 s, or `Options`-derived), `try { await CloseAsync(cts.Token); } catch (Exception ex) when (ex is OperationCanceledException or RpcException or MxGatewayException) { /* best-effort */ }`.
- Tests: add a test using a transport that never responds, asserting `DisposeAsync` completes within the bound and does not throw.
- Docs: note best-effort dispose semantics in `clients/dotnet/README.md`.
**Verification.** `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` + client tests.
---
## CLI-07 — .NET retry budget self-defeats on `DeadlineExceeded` `Medium` · `—`
**Finding.** In the .NET client, `ExecuteSafeUnaryAsync` caps the whole retry pipeline with `CancelAfter(Options.DefaultCallTimeout)` (`clients/dotnet/.../MxGatewayClient.cs:305-316`) while each attempt's `CallOptions` also gets a `DefaultCallTimeout` deadline (`:290-303`). Because `DeadlineExceeded` is in the retryable set (`MxGatewayClientRetryPolicy.cs:62-67`), a first attempt that ends in `DeadlineExceeded` consumes the entire outer budget, so the configured retries never run.
**Impact.** Retries are silently inert for the exact transient class they were configured for; `Unavailable`/`ResourceExhausted` bursts shorter than one full timeout still retry, but any deadline-driven transient does not.
**Design.** Give the outer budget headroom so retries have room to execute. Recommended: size the outer CTS to cover the retry math — roughly `MaxAttempts × (DefaultCallTimeout + MaxDelay)` — rather than a single `DefaultCallTimeout`. Alternative (simpler, less faithful to intent): drop `DeadlineExceeded` from `IsTransientStatus`. Prefer the headroom fix because per-attempt deadlines remain meaningful and deadline transients still retry. Open question the review couldn't settle: whether an absolute wall-clock cap should exist at all — recommend keeping one but computed from the retry parameters.
**Implementation.**
- `MxGatewayClient.cs` `ExecuteSafeUnaryAsync`: compute the outer `CancelAfter` from `Options.Retry` (attempts, delay, max-delay) plus per-attempt `DefaultCallTimeout`, instead of a bare `DefaultCallTimeout`.
- Tests: add a retry test whose first attempt returns `DeadlineExceeded` and asserts a second attempt occurs (the existing retry-policy test project already exercises the pipeline).
- Docs: clarify the timeout/retry interaction in `clients/dotnet/README.md`.
**Verification.** `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` + retry test.
---
## CLI-08 — .NET/Go/Java treat any nonzero HRESULT as failure `Medium` · `—`
**Finding.** `EnsureMxAccessSuccess` in .NET tests `reply.HasHresult && reply.Hresult != 0` (`clients/dotnet/.../MxCommandReplyExtensions.cs:32`); Go tests `reply.GetHresult() != 0` (`clients/go/mxgateway/errors.go:121`); Java's `MxGatewayErrors` does the same. This misclassifies positive COM success codes (e.g. `S_FALSE = 1`). Python uses the correct `reply.hresult < 0` (`clients/python/.../errors.py:133`).
**Impact.** A parity-preserving gateway reply carrying a positive success HRESULT throws in three clients and passes in one — a cross-client inconsistency that can turn a legitimate MXAccess success into a raised error. Co-designed with CLI-03 (Rust must adopt `< 0` from the start).
**Design.** Align .NET, Go, and Java on the COM-correct `hresult < 0` check (negative = failure), matching Python and MXAccess semantics. Low-risk one-line change per client; keep the status-array check unchanged. Not individually enumerated in the roadmap, but shares the correctness theme of P0 item 6 (see CLI-03) — land alongside the Rust MXSTATUS work.
**Implementation.**
- `.NET` `MxCommandReplyExtensions.cs:32`: `bool hResultFailure = reply.HasHresult && reply.Hresult < 0;`
- `Go` `errors.go:121`: `if reply.Hresult != nil && reply.GetHresult() < 0 {`
- `Java` `MxGatewayErrors.java` (~:50): change the `!= 0` comparison to `< 0`.
- Tests: add a case in each client asserting a reply with `hresult = 1` (S_FALSE) passes and `hresult = -2147...` fails; Python's existing regression suite already covers `< 0`.
- Docs: note the corrected semantics in each client README's error section.
**Verification.** `.NET` build+test; `Go` `go test ./...`; `Java` `gradle test` on windev (revert generated-file churn afterward).
---
## CLI-09 — Go has no typed auth-error mapping `Medium` · `—`
**Finding.** In the Go client, all RPC failures wrap into the generic `GatewayError` (`clients/go/mxgateway/errors.go:9-34`; call sites e.g. `client.go`), so distinguishing `Unauthenticated` from `PermissionDenied` requires `status.Code(errors.Unwrap(err))`. `docs/ClientLibrariesDesign.md:153` requires the two be treated distinctly, and the other four clients expose typed auth errors.
**Impact.** Go consumers cannot idiomatically branch on auth failure vs authorization failure — the documented contract is unmet only in Go.
**Design.** Add sentinel-backed typed errors `AuthenticationError` (gRPC `Unauthenticated`) and `AuthorizationError` (`PermissionDenied`) in `errors.go`, and map them at the transport boundary where `GatewayError` is currently constructed. Use `errors.Is`-friendly sentinels (`ErrUnauthenticated`, `ErrPermissionDenied`) plus wrapper structs preserving `Op` and the underlying `status.Status`. This mirrors the existing `MxAccessError`/`CommandError` layering already in the file.
**Implementation.**
- `errors.go`: add the two error types + sentinels and a helper `classifyRPCError(op string, err error) error` that inspects `status.Code(err)`.
- `client.go` (and other RPC wrappers): route transport errors through `classifyRPCError` instead of always constructing `GatewayError`.
- Tests: add cases asserting `errors.Is(err, ErrUnauthenticated)` / `ErrPermissionDenied` for the corresponding gRPC codes.
- Docs: update `clients/go/README.md` and flip the Go cell in the parity matrix (`docs/ClientLibrariesDesign.md`).
**Verification.** From `clients/go`: `gofmt -l .`, `go build ./...`, `go test ./...`.
---
## CLI-10 — Go uses deprecated `grpc.DialContext` + `grpc.WithBlock()` `Medium` · `—`
**Finding.** In the Go client, `Dial` builds options including `grpc.WithBlock()` and calls `grpc.DialContext` (`clients/go/mxgateway/client.go:60-68`). grpc-go ≥1.63 deprecates both in favor of `grpc.NewClient` with lazy connection.
**Impact.** Future grpc-go upgrades and `go vet`/staticcheck flag the deprecations; blocking dial also hides the per-RPC connection-error semantics the ecosystem now expects.
**Design.** Migrate to `grpc.NewClient` (lazy, non-blocking) and drop `grpc.WithBlock()`. Because `NewClient` no longer surfaces connect failures at dial time, expose readiness via a first `Ping` in the connect helpers that currently rely on blocking dial (or document that the first RPC surfaces connection errors). Preserve the existing transport-credential and auth-interceptor wiring unchanged.
**Implementation.**
- `client.go`: replace `grpc.DialContext(dialCtx, endpoint, dialOptions...)` with `grpc.NewClient(endpoint, dialOptions...)`, remove `grpc.WithBlock()`; if a caller-facing "connected" guarantee is desired, add an optional `Ping` in the `Dial`/connect wrapper.
- Tests: existing dial/TLS tests should pass; add a case asserting a bad endpoint surfaces on first RPC rather than at construction.
- Docs: note the lazy-connect behavior change in `clients/go/README.md`.
**Verification.** From `clients/go`: `go build ./...`, `go vet ./...`, `go test ./...`.
---
## CLI-11 — Go CLI cannot opt into strict TLS validation `Medium` · `—`
**Finding.** In the Go client CLI, `dialForCommand` never sets `Options.RequireCertificateValidation` and exposes no flag for it (`clients/go/cmd/mxgw-go/main.go:1151-1158`), so every non-CA-pinned TLS run of `mxgw-go` is skip-verify even though the library supports strictness (`clients/go/mxgateway/client.go:231-241`).
**Impact.** `mxgw-go --tls` without a CA cannot authenticate the server at all, with no operator opt-in — a security-posture gap unique to the Go CLI. Ties into CLI-17 (cross-client TLS convergence).
**Design.** Add a `-require-certificate-validation` boolean flag that sets `Options.RequireCertificateValidation`, mirroring the library capability. Coordinate the default with CLI-17's convergence decision; at minimum emit a one-line stderr warning when TLS runs skip-verify (see CLI-17/CLI-20).
**Implementation.**
- `clients/go/cmd/mxgw-go/main.go`: register the flag and thread it into `dialForCommand`'s `Options`.
- Tests: extend the CLI/TLS tests to assert the flag sets the option.
- Docs: document the flag in `clients/go/README.md` and the CLI usage.
**Verification.** From `clients/go`: `go build ./...`, `go test ./...`.
---
## CLI-12 — Java docs still say "Java 21" after the JDK 17 retarget `Medium` · `P2`
**Finding.** After the verified-correct JDK 17 retarget (build.gradle `toolchain 17` + `options.release = 17`, `build.gradle:20-31`), three docs still claim Java 21: `clients/java/README.md:354`, `clients/java/JavaClientDesign.md:34-35`, and `docs/ClientPackaging.md:193`. This violates CLAUDE.md's docs-in-same-commit rule on the retarget's own branch.
**Impact.** Consumers targeting Ignition 8.3 (JDK 17) read contradictory prerequisites. Roadmap P2 item 15 (doc-drift sweep).
**Design.** Sweep all three references from "Java 21" to "Java 17" (noting a 17-targeted build still runs on 21+, as `build.gradle:20-21` already comments). Pure documentation change; no code. Land on the `feat/jdk17-client-retarget` branch before merge.
**Implementation.**
- `clients/java/README.md:354`, `clients/java/JavaClientDesign.md:34-35`, `docs/ClientPackaging.md:193`: change "Java 21" → "Java 17" (and correct the Gradle-toolchain wording). Note the `docs/ClientPackaging.md:193` line also carries the stale Java package/naming addressed in CLI-16 — fix together.
- Docs only; no tests.
**Verification.** Grep confirms zero remaining "Java 21" under `clients/java` and `docs/`: `grep -rn "Java 21" clients/java docs/`. No build needed for the doc change.
---
## CLI-13 — Java event-stream buffer hardcoded 16, cancel-on-overflow `Medium` · `—`
**Finding.** In the Java client, `MxGatewayClient.streamEvents` constructs `new MxEventStream(16)` (`clients/java/.../MxGatewayClient.java:248`) with no configuration, and overflow cancels the RPC (`MxEventStream.java:124-132`). Unlike Go (CLI-01), the failure is at least surfaced as `MxGatewayException("…queue overflowed")`, but a consumer stalling for 16 events is disconnected even though gRPC has native flow control.
**Impact.** Slow Java consumers are force-disconnected at an arbitrary 16-event threshold; capacity is not tunable. Backpressure semantics diverge from the unbuffered .NET/Python/Rust clients (CLI-33).
**Design.** Make the buffer capacity an option on the stream-events call (default 16 for compatibility) and/or adopt manual flow control (`disableAutoRequestWithInitial` on the stub) so backpressure rides gRPC instead of cancel-on-overflow. Recommended minimal change: parameterize capacity via an overload / options object; the manual-flow-control migration is a larger follow-up. Keep the loud `MxGatewayException` on overflow (Java's surfaced-error behavior is already correct relative to Go).
**Implementation.**
- `MxGatewayClient.java`: add an overload `streamEvents(StreamEventsRequest, int bufferCapacity)` (or an options field) feeding `new MxEventStream(capacity)`.
- `MxEventStream.java`: no behavior change required for the capacity option; document the overflow contract (see CLI-24).
- Tests: extend the Java event-stream tests with a custom-capacity case and an overflow case asserting the `MxGatewayException` message.
- Docs: record Java's buffered/error-on-overflow behavior in `docs/ClientBehaviorFixtures.md` (CLI-33) and `clients/java/README.md`.
**Verification.** `gradle test` from `clients/java` **on windev** (no local JRE); revert the regenerated `MxaccessGateway.java` churn afterward since no `.proto` changed.
---
## CLI-14 — Python default TLS is blocking TOFU pin with silent `localhost` SNI `Medium` · `—`
**Finding.** In the Python client, `create_channel`'s default TLS path fetches the server certificate unverified and pins it trust-on-first-use (`clients/python/.../options.py:128-160`), and silently defaults the SNI/target-name override to `localhost` when none is supplied (`options.py:153-154`). It is documented and bounded (probe timeout; `asyncio.to_thread`), but it is the only client that opens a second out-of-band TCP+TLS connection per channel, and TOFU is exposed to first-contact interception.
**Impact.** A first-contact MITM can pin a rogue certificate; the silent `localhost` SNI can mask a hostname mismatch. Bounded and intentional for the internal-tool posture, but the threat window is undocumented in the client's own README. Ties into CLI-17.
**Design.** Keep the TOFU default (removing it would break the internal-tool ergonomics the other clients also target) but document the MITM window in the README threat model, prefer `ca_file` in examples, and make the `localhost` SNI default explicit in a log/warning rather than silent. No behavioral break; a warning + doc change. Convergence across languages is CLI-17.
**Implementation.**
- `clients/python/README` (or `docs/`): add a threat-model note describing the first-contact TOFU window and recommending `ca_file`; make `ca_file` the primary TLS example.
- `options.py`: emit a one-line warning (via `logging`/`warnings`) when the TOFU path pins an unverified cert and when it auto-applies the `localhost` SNI override.
- Tests: extend the Python TLS tests to assert the warning fires on the TOFU path.
- Docs: cross-reference `docs/CrossLanguageSmokeMatrix.md`'s TLS divergence table.
**Verification.** `python -m pytest` from `clients/python`.
---
## CLI-15 — No client handles `ReplayGap` or offers a reconnect helper `Medium` · `P2`
**Finding.** All five clients expose the `after_worker_sequence` resume cursor (`MxGatewaySession.cs:865-876`, `session.go:682-688`, `MxGatewaySession.java:718-722`, `session.py:571-582`, `session.rs:648`), but none handle the `ReplayGap` sentinel a resuming consumer must expect — `grep ReplayGap clients/` returns only build output (contracts XML, javadoc), no handwritten source. The gateway shipped `DetachGraceSeconds` + replay (on by default), so a resuming stream can legitimately emit a gap event that no client types or documents.
**Impact.** A client that reconnects with `after_worker_sequence` after the replay buffer overran receives a `ReplayGap` it does not recognize, silently mis-treating a lossy resume as continuous. Roadmap P2 item 12 (finish the session-resilience epic). Coordinates with CLI-04 (session-surface pass) and cross-domain with the gateway/testing reports.
**Design.** Two-stage: (1) document `ReplayGap` gap-detection per client README so consumers can branch on it via the raw event; (2) add a typed helper/observer that raises or signals gap detection during resume. Stage 1 is the P2-minimum. Recommended stage-2 shape: a per-client `on_replay_gap`/`ReplayGapError` hook surfaced from the event iterator when a `ReplayGap` event arrives, letting the consumer decide whether to re-snapshot. Do not synthesize or swallow the gap — forward the gateway's event faithfully (per the no-synthesized-events invariant); the client only makes it observable and typed.
**Implementation.**
- Each client README: document that resuming with a stale `after_worker_sequence` can yield a `ReplayGap` event and what it means.
- `.NET`/`Go`/`Java`/`Python`/`Rust` event surfaces: recognize the `ReplayGap` event kind and expose it as a distinct typed result/error alongside normal events.
- Tests: add per-client fixture cases feeding a `ReplayGap` event and asserting the typed surface; add a resume-with-gap case to the cross-language smoke matrix.
- Docs: `docs/ClientLibrariesDesign.md` (update the "no client-side reconnect" v1 non-goal, `:64-70`) and `docs/CrossLanguageSmokeMatrix.md`.
**Verification.** Per-language build/test as in CLI-04 (Java on windev, revert generated churn).
---
## CLI-16 — `docs/ClientPackaging.md` drifted from Python naming and .NET `.slnx` `Medium` · `P2`
**Finding.** `docs/ClientPackaging.md` names the Python package `mxaccess-gateway-client` and its generated dir `src/mxgateway/generated` (`:159-160`) vs the actual `zb-mom-ww-mxaccess-gateway-client` (`clients/python/pyproject.toml:8`) and `src/zb_mom_ww_mxgateway/generated`; the CLI module `python -m mxgateway_cli` (`:187`) vs actual `zb_mom_ww_mxgateway_cli`; the .NET solution `ZB.MOM.WW.MxGateway.Client.sln` (`:51-52`) vs actual `.slnx`; and Java 21 (`:193`, CLI-12). `docs/ClientLibrariesDesign.md:410` repeats the stale Python generated path.
**Impact.** Copy-paste build/run commands from the packaging doc fail — wrong package name, wrong module path, wrong solution extension. Roadmap P2 item 15.
**Design.** One documentation sweep aligning every reference to reality. Pure docs; no code. Land with CLI-12 (same file, `:193`).
**Implementation.**
- `docs/ClientPackaging.md`: `:51-52` `.sln``.slnx`; `:159-160` package → `zb-mom-ww-mxaccess-gateway-client`, generated dir → `src/zb_mom_ww_mxgateway/generated`; `:187` CLI module → `python -m zb_mom_ww_mxgateway_cli`; `:193` Java 21 → 17 and package naming.
- `docs/ClientLibrariesDesign.md:410`: generated path → `clients/python/src/zb_mom_ww_mxgateway/generated`.
- Verify against `clients/python/pyproject.toml` and `clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx`.
**Verification.** Grep the corrected tokens back against the source: `grep -n "slnx\|zb_mom_ww_mxgateway" docs/ClientPackaging.md` and confirm no stale `mxgateway_cli`/`src/mxgateway/generated` remain.
---
## CLI-17 — TLS default posture inconsistent across the five clients `Medium` · `—`
**Finding.** The five clients diverge on default TLS: accept-any-cert in .NET (`MxGatewayClient.cs:361-364`), Go (`client.go:236-240`, `InsecureSkipVerify`), and Java (`InsecureTrustManagerFactory`); TOFU pinning in Python (`options.py:133-154`); strict pin-only in Rust. `docs/CrossLanguageSmokeMatrix.md:58-66` documents the divergence, but the same `--tls`-without-CA invocation authenticates the server in one language, half in another, and not at all in three.
**Impact.** Inconsistent, surprising security posture per language for identical invocations. Depends on CLI-14 (Python) and interacts with CLI-11/CLI-20.
**Design.** Converge, or at minimum make the weak default loud. Recommended target: the Python TOFU model as the shared default (pins on first contact, better than accept-all), and where a client keeps accept-all as an explicit opt-in, emit a one-line stderr/log warning whenever certificate verification is disabled. Full convergence is a larger cross-client effort; the review's minimum is the warning. Open question the review left: whether to make strict validation the default and require an explicit `--insecure`-style opt-out — recommended long-term but a breaking change for existing internal-tool callers, so stage it behind a major version.
**Implementation.**
- `.NET` `MxGatewayClient.cs:361-364`, `Go` `client.go`, `Java` TLS path: emit a single warning when the accept-all callback / `InsecureSkipVerify` / `InsecureTrustManagerFactory` is installed (see CLI-20 for the .NET-specific note).
- Optionally add TOFU pinning to .NET/Go/Java to match Python (larger; stage separately).
- Tests: assert the warning fires when TLS runs without a CA and without strict validation.
- Docs: reconcile `docs/CrossLanguageSmokeMatrix.md:58-66` with the chosen convergence and note the warning behavior in each README.
**Verification.** Per-language build/test (Java on windev).
---
## CLI-18 — .NET csproj records no `<Version>` `Low` · `P2`
**Finding.** `clients/dotnet/.../ZB.MOM.WW.MxGateway.Client.csproj` (packaging block lines 19-28) declares no `<Version>`; the published NuGet version (0.1.2) is supplied out-of-band at pack time, so the source tree does not record what ships.
**Impact.** The repo cannot be inspected to learn the shipped version; drift risk. Roadmap P2 item 16 (version alignment).
**Design.** Add `<Version>` to the csproj `PropertyGroup` so the source records the shipped version, keeping the pack-time override available for CI. Trivial.
**Implementation.**
- `ZB.MOM.WW.MxGateway.Client.csproj`: add `<Version>0.1.2</Version>` (or the current release) to the packaging `PropertyGroup` (lines 19-28).
- Docs: none beyond the version-alignment note in the packaging doc.
**Verification.** `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx`.
---
## CLI-19 — .NET duplicate `InternalsVisibleTo` `Low` · `—`
**Finding.** `InternalsVisibleTo("ZB.MOM.WW.MxGateway.Client.Tests")` is declared both in `clients/dotnet/.../Properties/AssemblyInfo.cs:3` and as a csproj `AssemblyAttribute` (`ZB.MOM.WW.MxGateway.Client.csproj:35-39`). Harmless but redundant.
**Impact.** Cosmetic; two sources of truth for the same attribute.
**Design.** Keep one. Recommend the csproj `AssemblyAttribute` block (co-located with packaging config) and delete `Properties/AssemblyInfo.cs`, or vice versa.
**Implementation.**
- Delete `clients/dotnet/.../Properties/AssemblyInfo.cs` (or remove the csproj block) — keep exactly one declaration.
**Verification.** `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` (confirms tests still see internals).
---
## CLI-20 — .NET `--tls` without CA installs accept-all callback `Low` · `—`
**Finding.** In the .NET client, with `UseTls` and no CA file, the default `RequireCertificateValidation = false` installs an accept-all certificate callback (`clients/dotnet/.../MxGatewayClient.cs:361-364`). Documented and intentional (`MxGatewayClientOptions.cs:31-36`), but `--tls` without a CA gives no server authentication.
**Impact.** Same class as CLI-17; called out separately for the .NET default. Low because documented/intentional.
**Design.** Emit a one-line warning when the accept-all callback is installed (co-designed with CLI-17). No behavior change to the default.
**Implementation.**
- `MxGatewayClient.cs`: log a warning (via the client's `ILogger`) when the accept-all `RemoteCertificateValidationCallback` is installed (line 361-364 branch).
- Docs: cross-reference CLI-17 in `clients/dotnet/README.md`.
**Verification.** `dotnet build` + client TLS-handler test asserting the warning.
---
## CLI-21 — Go `ClientVersion` stale vs tagged releases `Low` · `P2`
**Finding.** In the Go client, `ClientVersion = "0.1.0-dev"` (`clients/go/mxgateway/version.go:6`) is stale relative to the module releases published via `scripts/tag-go-module.ps1`.
**Impact.** `mxgw-go` and callers report a dev version regardless of the tagged release. Roadmap P2 item 16.
**Design.** Bump `ClientVersion` to the released value and update it as part of the tagging script so the two cannot drift.
**Implementation.**
- `clients/go/mxgateway/version.go:6`: set to the current release (e.g. `0.1.2`).
- `scripts/tag-go-module.ps1`: update the constant (or fail the tag) when the version constant does not match the tag.
- Docs: version-alignment note in the packaging doc.
**Verification.** From `clients/go`: `go build ./...`, `go test ./...`.
---
## CLI-22 — Go `newCorrelationID` swallows `crypto/rand` error `Low` · `—`
**Finding.** In the Go client, `newCorrelationID` returns an empty string on `rand.Read` error (`clients/go/mxgateway/session.go:786-792`), silently dropping traceability.
**Impact.** A rare `crypto/rand` failure yields empty correlation ids, breaking request tracing with no signal.
**Design.** Fall back to a monotonic timestamp+counter id rather than empty, preserving uniqueness/traceability without introducing an error-return on a hot helper.
**Implementation.**
- `session.go:786-792`: on `rand.Read` error, build a fallback id from `time.Now().UnixNano()` plus an atomic counter.
- Tests: add a case (via an injectable rand source or by asserting non-empty output) that the id is never empty.
**Verification.** From `clients/go`: `go test ./...`.
---
## CLI-23 — Go nil-vs-empty bulk short-circuit asymmetry `Low` · `—`
**Finding.** In the Go client, `WriteBulk`/`ReadBulk` short-circuit an empty (non-nil) slice locally (`session.go:399-407`, `:524-532`) while `AddItemBulk`/`SubscribeBulk` send an empty command to the wire (`session.go:255-274`). Harmless but inconsistent within one file.
**Impact.** Cosmetic; identical inputs produce a local no-op in some helpers and a wire round-trip in others.
**Design.** Pick one convention (recommend short-circuit empty locally, avoiding a pointless round-trip) and apply it uniformly across the bulk helpers.
**Implementation.**
- `session.go`: make `AddItemBulk`/`SubscribeBulk` (and siblings) short-circuit empty input like `WriteBulk`/`ReadBulk`, or document the intentional difference.
- Tests: assert empty-input behavior is consistent.
**Verification.** From `clients/go`: `go test ./...`.
---
## CLI-24 — Java `MxEventStream` single-consumer constraint undocumented `Low` · `—`
**Finding.** In the Java client, `MxEventStream` is a single-consumer iterator with unsynchronized `next` state (`MxEventStream.java:31`, `:65-92`); the single-consumer constraint is not documented.
**Impact.** A caller iterating from two threads corrupts `next` state with no warning.
**Design.** Add a Javadoc note stating the iterator is single-consumer / not thread-safe. Documentation only (matching CLI-13's overflow doc).
**Implementation.**
- `MxEventStream.java`: Javadoc on the class and `next` noting single-consumer usage.
**Verification.** `gradle test` on windev (revert generated churn); Javadoc-only change.
---
## CLI-25 — Java `close()` does not await channel termination `Low` · `—`
**Finding.** In the Java client, `close()` initiates `shutdown()` without awaiting termination (`MxGatewayClient.java:346-351`); `closeAndAwaitTermination()` exists (`:360-367`), but try-with-resources users can leak a channel briefly at JVM exit.
**Impact.** Minor resource leak window on `try (var client = ...)` teardown.
**Design.** Make `close()` await a short bounded termination (e.g. a few seconds) so try-with-resources users get clean teardown, keeping `closeAndAwaitTermination()` for explicit longer bounds.
**Implementation.**
- `MxGatewayClient.java` `close()`: after `shutdown()`, `awaitTermination(shortBound, SECONDS)` (swallow/timeout gracefully).
- Tests: assert `close()` completes and the channel is terminated.
- Docs: note the bounded-await in `clients/java/README.md`.
**Verification.** `gradle test` on windev; revert generated churn.
---
## CLI-26 — Python `version.py` ≠ `pyproject.toml` `Low` · `P2`
**Finding.** In the Python client, `pyproject.toml:9` says `0.1.2` while `version.py:3` says `__version__ = "0.1.0"`, so `mxgw-py version` reports the wrong value.
**Impact.** CLI and callers report a stale version. Roadmap P2 item 16.
**Design.** Derive `__version__` from installed metadata so the two cannot drift: `importlib.metadata.version("zb-mom-ww-mxaccess-gateway-client")` with a fallback for editable/source runs. Aligns with the single-source-of-truth intent.
**Implementation.**
- `clients/python/src/zb_mom_ww_mxgateway/version.py`: replace the literal with `importlib.metadata.version(...)` guarded by `try/except PackageNotFoundError` falling back to a literal.
- Tests: assert `version.__version__` matches `pyproject` (or the installed dist) in the packaging/regression suite.
- Note: any Python regen for this work must pin `grpcio-tools` to the baseline (grpcio 1.80.0 / protobuf 6.31.1) — but this change touches no protos, so no regen is needed.
**Verification.** `python -m pytest` from `clients/python`.
---
## CLI-27 — Python `Session.close()` not concurrency-safe `Low` · `—`
**Finding.** In the Python client, `Session.close()` has no lock around `_closed` (`session.py:38-55`) and repeated closes return a locally synthesized `CloseSessionReply` rather than the cached server reply — divergent from .NET/Go which cache the real reply.
**Impact.** Concurrent closes can double-invoke; repeated closes return a synthetic reply instead of the server's. Minor.
**Design.** Add an `asyncio.Lock` around the close transition and cache the real server `CloseSessionReply` to return on repeat, matching the .NET/Go cached-reply pattern.
**Implementation.**
- `session.py`: guard `_closed` with an `asyncio.Lock`, store the first server reply, return it on subsequent calls.
- Tests: assert concurrent `close()` calls invoke the server once and return the cached reply.
**Verification.** `python -m pytest` from `clients/python`.
---
## CLI-28 — Python circular-import workaround at end of `session.py` `Low` · `—`
**Finding.** In the Python client, `from .client import GatewayClient` sits at the bottom of `session.py` with `# noqa: E402` (~`session.py:590`) to break a runtime import cycle.
**Impact.** Works, but the runtime cycle is fragile and lints noisily.
**Design.** Replace with a `TYPE_CHECKING`-guarded import plus a string annotation, removing the runtime cycle entirely.
**Implementation.**
- `session.py`: `if TYPE_CHECKING: from .client import GatewayClient` at the top and annotate the reference as `"GatewayClient"`; delete the bottom-of-file import and `# noqa`.
- Tests: existing import/session tests confirm no runtime cycle.
**Verification.** `python -m pytest` from `clients/python`.
---
## CLI-29 — Rust `CLIENT_VERSION` ≠ `Cargo.toml` `Low` · `P2`
**Finding.** In the Rust client, `CLIENT_VERSION = "0.1.0-dev"` with a doc comment claiming it "Mirrors `Cargo.toml`" (`clients/rust/src/version.rs:6-7`) while `Cargo.toml:3` says `0.1.2`.
**Impact.** The advertised client version is wrong and contradicts its own doc. Roadmap P2 item 16.
**Design.** Source the constant from Cargo at compile time so it cannot drift: `env!("CARGO_PKG_VERSION")`.
**Implementation.**
- `clients/rust/src/version.rs:7`: `pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");`.
- Tests: add a trivial assertion that `CLIENT_VERSION` equals the expected release, or that it is non-empty and matches `Cargo.toml` via a build script check.
**Verification.** From `clients/rust`: `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings`.
---
## CLI-30 — Rust has no `unregister` typed helper `Low` · `—`
**Finding.** In the Rust client, the session surface (`clients/rust/src/session.rs:119-231`) covers register/add/advise/remove but not `Unregister`; Go/Java/Python expose it (and .NET also lacks it per the parity matrix).
**Impact.** Rust (and .NET) users must drop to raw `Invoke` to unregister — a small parity gap.
**Design.** Add an `unregister` typed helper wrapping the existing raw-command machinery, for symmetry with the other clients. Fold into the CLI-04 session-surface pass.
**Implementation.**
- `clients/rust/src/session.rs`: add `pub async fn unregister(...)` mirroring `register`.
- (Optionally add the .NET `UnregisterAsync` too, closing the matrix cell.)
- Tests: fixture/unit coverage for `unregister`.
- Docs: flip the parity-matrix cell in `docs/ClientLibrariesDesign.md`.
**Verification.** From `clients/rust`: `cargo check/test/clippy`.
---
## CLI-31 — Rust CLI is a single 2,699-line `main.rs` `Low` · `—`
**Finding.** In the Rust client, the CLI is one 2,699-line `main.rs` (`clients/rust/crates/mxgw-cli/src/main.rs`), the largest single file in the client tree; the Windows stack-size workaround it forced (`clients/rust/.cargo/config.toml:1-19`) is evidence the command enum has outgrown one module.
**Impact.** Maintainability; large compile unit; the stack-size workaround is a symptom.
**Design.** Split subcommands into per-command modules (`mxgw-cli/src/commands/*.rs`) with `main.rs` reduced to arg parsing and dispatch. Mechanical refactor; behavior-preserving. Re-evaluate whether the `.cargo/config.toml` stack workaround can be relaxed after the split.
**Implementation.**
- `clients/rust/crates/mxgw-cli/src/`: extract subcommand handlers into modules; keep the clap/enum wiring in `main.rs`.
- Tests: existing CLI tests must pass unchanged.
**Verification.** From `clients/rust`: `cargo build`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings`.
---
## CLI-32 — Client-side bulk caps differ `Low` · `—`
**Finding.** Go/Python/Rust enforce a 1,000-item client-side bulk cap (`session.go:19`, `session.py:11`, `session.rs:29`) while .NET and Java send unbounded lists and rely on the gateway. Same oversized call produces different error types per language.
**Impact.** Inconsistent error surface for oversized bulk calls across languages. Harmless functionally.
**Design.** Align: either all clients enforce the 1,000 cap locally (recommended — a fast, uniform client-side error) or none do. Add the cap to .NET and Java.
**Implementation.**
- `.NET` (`MxGatewaySession.cs`) and `Java` (`MxGatewaySession.java`): add a client-side 1,000-item check on the bulk helpers, throwing the client's standard argument/validation error.
- Tests: assert an oversized bulk call fails locally with the expected error type in .NET and Java.
- Docs: note the uniform cap in the client READMEs.
**Verification.** `.NET` build+test; `Java` `gradle test` on windev (revert generated churn).
---
## CLI-33 — Per-language event backpressure semantics undocumented `Low` · `—`
**Finding.** Event-stream backpressure differs by language: Go buffered-16 / silent-cancel (CLI-01), Java buffered-16 / error-cancel (CLI-13), .NET/Python/Rust unbuffered (native gRPC flow control). This per-language slow-consumer behavior is a parity-relevant observable absent from `docs/ClientBehaviorFixtures.md`.
**Impact.** Consumers cannot predict slow-consumer behavior from the docs. Depends on CLI-01 and CLI-13 landing first (they change the Go/Java behavior being documented).
**Design.** After CLI-01 (Go now emits a terminal `ErrSlowConsumer`) and CLI-13 (Java capacity option), document the per-language slow-consumer contract in `docs/ClientBehaviorFixtures.md` as a parity fixture row. Documentation only.
**Implementation.**
- `docs/ClientBehaviorFixtures.md`: add a backpressure/slow-consumer section stating each client's behavior (Go: terminal `ErrSlowConsumer`; Java: `MxGatewayException` on overflow, configurable capacity; .NET/Python/Rust: native gRPC flow control).
- Optionally add a shared behavior-fixture case exercised by each client's fixture-driven tests.
**Verification.** Doc change; validated by the fixture-driven test suites already run per client.
---
## CLI-34 — Python `build/`/`.pytest_cache/` present on disk (untracked) `Low` · `—`
**Finding.** In the Python client, `build/` and `.pytest_cache/` exist on disk but are not git-tracked (report X7; generated-code hygiene is otherwise good across all clients — tracked generated dirs match the manifest, Rust uses `OUT_DIR` + `.gitkeep`, .NET references Contracts directly).
**Impact.** None functionally; risk of an accidental future commit of build artifacts.
**Design.** Confirm `clients/python/.gitignore` (or the repo root `.gitignore`) covers `build/` and `.pytest_cache/`; add entries if absent. No code change.
**Implementation.**
- Verify/extend `.gitignore` to cover `clients/python/build/` and `clients/python/.pytest_cache/`.
**Verification.** `git status --ignored clients/python` shows the directories ignored, not untracked.