# Code Review — Client.Python

| Field | Value |
|---|---|
| Module | `clients/python` |
| Reviewer | Claude Code |
| Review date | 2026-06-16 |
| Commit reviewed | `8df5ab3` |
| Status | Re-reviewed |
| Open findings | 0 |

## Checklist coverage

### 2026-06-16 re-review (commit 8df5ab3)

Re-review of the Python client delta: new galaxy CLI commands, options.py TLS/auth, large test additions. Prior Client.Python-027..031 confirmed resolved. One claimed regression (Python-004 dead variable) and one Medium README/API mismatch.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Client.Python-032, Client.Python-033, Client.Python-034 |
| 2 | mxaccessgw conventions | No issues found |
| 3 | Concurrency & thread safety | No issues found |
| 4 | Error handling & resilience | No issues found |
| 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | No issues found |
| 8 | Code organization & conventions | Client.Python-035 |
| 9 | Testing coverage | Client.Python-036 |
| 10 | Documentation & comments | Client.Python-036 |

### 2026-06-15 re-review (commit 410acc9)

Re-review pass at `410acc9`. The diff against the previous review base
`42b0037` covers: PyPI metadata + Gitea PyPI-feed install instructions in
`pyproject.toml` / `README.md`; a new lazy Galaxy browse surface
(`GalaxyRepositoryClient.browse_children_raw` / `browse` / `_iter_browse_children`,
the `LazyBrowseNode` walker, and `BrowseChildrenOptions`); a TLS
trust-on-first-use (TOFU) default in `options.py` gated by a new
`ClientOptions.require_certificate_validation` flag; the `_use_plaintext`
TLS-default contract carried forward; and the `batch` `CliRunner`-removal
follow-through. The new browse / TOFU surface is well tested
(`tests/test_galaxy.py`, `tests/test_auth_options.py`, `tests/test_tls.py`).

`python -m pytest` passes (80 passed, 1 skipped — the loopback-TLS test is
opt-in via `MXGATEWAY_RUN_TLS_TESTS=1`). `python -m pip wheel .` builds the
wheel cleanly against the installed setuptools 82.0.1.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Issue found: `_split_authority` raises an uncaught `ValueError` for a port-less endpoint instead of a transport error (Client.Python-029). |
| 2 | mxaccessgw conventions | No new issues found — secrets still redacted, generated code untouched, no committed tokens in the new Gitea feed URLs (placeholders only). |
| 3 | Concurrency & thread safety | No new issues found — `LazyBrowseNode.expand` uses a per-node `asyncio.Lock` with a double-checked guard and is verified concurrent-safe by `test_browse_expand_concurrent_callers_only_fire_one_rpc`. |
| 4 | Error handling & resilience | Issue found: the TOFU branch calls the blocking `ssl.get_server_certificate` with no timeout from inside the `async def connect` path, blocking the event loop and hanging indefinitely on a black-holed host (Client.Python-028). |
| 5 | Security | Issue found: the new `require_certificate_validation` security flag is not reachable through the documented `connect(...)` convenience kwargs or any CLI flag, so callers using those paths are locked into TOFU and cannot force certificate validation (Client.Python-027). TOFU itself is design-sanctioned (`docs/GatewayConfiguration.md` line 470). |
| 6 | Performance & resource management | No new issues found beyond the blocking TLS probe captured in Client.Python-028. |
| 7 | Design-document adherence | No new issues found — TOFU default, `require_certificate_validation` naming, and the BrowseChildren surface match `docs/GatewayConfiguration.md` / `docs/GalaxyRepository.md`; both README doc anchors resolve. |
| 8 | Code organization & conventions | Issue found: `pyproject.toml` uses the PEP 639-deprecated `license = { text = ... }` table form (Client.Python-030). pyproject metadata is otherwise correct and the wheel builds. |
| 9 | Testing coverage | Issue found: the `tls` pytest mark used by `tests/test_tls.py` is not registered in `[tool.pytest.ini_options]`, emitting a `PytestUnknownMarkWarning` (Client.Python-031). New browse / TOFU paths are otherwise well covered. |
| 10 | Documentation & comments | No new issues found — README TLS/browse/Gitea-feed prose matches the code; the alarm-CLI README examples corrected under Client.Python-022 remain correct. |

### Prior coverage (commit a020350)

A re-review at commit `a020350` over the same module. Prior findings
(Client.Python-001 — Client.Python-017) remain closed and are kept as
history. This section reflects categories evaluated in that pass.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | No new issues found — TLS-by-default fix in Client.Python-013 verified; no test fixture accidentally relies on plaintext defaults. |
| 2 | mxaccessgw conventions | No new issues found — secrets redacted, MXAccess parity preserved, generated code untouched. |
| 3 | Concurrency & thread safety | No new issues found — close-idempotency and shared cancel-on-cancel iterator still in place. |
| 4 | Error handling & resilience | No new issues found. |
| 5 | Security | No new issues found — `_use_plaintext` now requires explicit `--plaintext` opt-in (Client.Python-013 resolution verified). The `--api-key` flag is also still redacted from the option repr and CLI errors. |
| 6 | Performance & resource management | No new issues found. |
| 7 | Design-document adherence | No new issues found — `PythonClientDesign.md` is consistent with the implemented surface. |
| 8 | Code organization & conventions | Issue found: `mxgateway_cli` is shipped in the wheel but has no PEP 561 `py.typed` marker (Client.Python-019), so the CLI module's inline type hints are invisible to downstream `mypy` runs. |
| 9 | Testing coverage | Issue found: no test exercises the wheel-build / editable-install flow; the broken `pyproject.toml` (Client.Python-018) was not caught at commit time because the test suite runs from `src/` via `pytest pythonpath` (Client.Python-020). |
| 10 | Documentation & comments | Issue found: cross-client CLI parity gap — the Python CLI ships none of the Galaxy subcommands (`galaxy-test-connection`, `galaxy-last-deploy`, `galaxy-discover`, `galaxy-watch`) the .NET / Go / Rust / Java CLIs all expose, and lacks the new `.NET`-only `bench-stream-events`. README does not flag the gap (Client.Python-021). |

### 2026-05-24 re-review (commit 42b0037)

Re-review pass at `42b0037`. The diff against the previous review base
`d692232` is four commits affecting `clients/python`:

- `71d2c39` e2e: port `batch` subcommand to all five client CLIs
- `6add4b4` Python client: port bulk read/write SDK methods + CLI subcommands
- `828e3e6` Python client: port stream-alarms and acknowledge-alarm
- `8738735` clients: document StreamAlarms + AcknowledgeAlarm in each README

Surface area added: `Session.read_bulk` / `write_bulk` / `write2_bulk` /
`write_secured_bulk` / `write_secured2_bulk`; `GatewayClient.stream_alarms`
+ `_canceling_alarm_feed_iterator`; the corresponding CLI subcommands
`read-bulk`, `write-bulk`, `write2-bulk`, `write-secured-bulk`,
`write-secured2-bulk`, `bench-read-bulk`, `stream-alarms`,
`acknowledge-alarm`, and `batch`; new README CLI examples for the alarm
subcommands; new CLI tests for `stream-alarms` / `acknowledge-alarm`
registration and `batch` semantics.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Issue found: `bench-read-bulk` does a function-local `import time` and uses bare `except Exception: pass` in cleanup blocks (Client.Python-026). |
| 2 | mxaccessgw conventions | No new issues found — secured writes still redact, generated code untouched, MXAccess parity preserved. |
| 3 | Concurrency & thread safety | No new issues found — `_canceling_alarm_feed_iterator` follows the same shape as `_canceling_iterator` (Client.Python-007 helper). |
| 4 | Error handling & resilience | No new issues found in the new SDK methods; new RPC mapping `map_rpc_error("stream alarms", ...)` is correct. |
| 5 | Security | Issue found: `_use_plaintext` localhost auto-plaintext branch resolved under Client.Python-013 is back in the renamed CLI module and was carried forward through the new commit untouched (Client.Python-023). |
| 6 | Performance & resource management | No new issues found. |
| 7 | Design-document adherence | No new issues found in the new alarm / bulk surface — matches the cross-client parity matrix expectation. |
| 8 | Code organization & conventions | Issue found: the new `batch` subcommand uses `click.testing.CliRunner` from production code (Client.Python-024). |
| 9 | Testing coverage | Issue found: the new SDK methods and most of the new CLI subcommand bodies have no behavioural tests — only `--help` smoke tests for the alarm CLI (Client.Python-025). |
| 10 | Documentation & comments | Issue found: the README CLI examples for `stream-alarms` and `acknowledge-alarm` use flags that do not exist on the implemented commands (Client.Python-022). |

### 2026-05-24 review (commit d692232)

Re-review pass at `d692232`. Diff against `a020350` is commit `397d3c5`:
package directories renamed (`src/mxgateway` → `src/zb_mom_ww_mxgateway`,
`src/mxgateway_cli` → `src/zb_mom_ww_mxgateway_cli`), distribution name
changed to `zb-mom-ww-mxaccess-gateway-client`, console-script
`mxgw-py` retained, every `from mxgateway` / `import mxgateway` updated.
A first-pass case-insensitive regex sweep corrupted the binary descriptor
bytes in the generated `_pb2.py` files; the fix was to restore the
original `_pb2.py` artifacts from the pre-rename directory before
deleting it, so the csharp_namespace bytes still carry the old string —
this is documented as wire-level metadata not used by Python at runtime.
Hostname / cert / temp-dir example identifiers (`mxgateway.example.local`,
`mxgateway-ca.pem`, `mxgateway-python-wheel`) were intentionally preserved.

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | No issues found in the a020350..d692232 diff. |
| 2 | mxaccessgw conventions | No issues found — wire identifiers preserved. |
| 3 | Concurrency & thread safety | No issues found in this diff. |
| 4 | Error handling & resilience | No issues found in this diff. |
| 5 | Security | No issues found in this diff. |
| 6 | Performance & resource management | No issues found in this diff. |
| 7 | Design-document adherence | No issues found — `PythonClientDesign.md` reflects new paths. |
| 8 | Code organization & conventions | No issues found in this diff. |
| 9 | Testing coverage | No issues found in this diff — alarm test fixtures correctly drop retired `session_id` from `AcknowledgeAlarmRequest` while retaining it on `QueryActiveAlarmsRequest`. |
| 10 | Documentation & comments | No issues found in this diff. |

## Findings

### Client.Python-001

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `clients/python/pyproject.toml:8,25`, `clients/python/src/mxgateway_cli/commands.py:25` |
| Status | Resolved |

**Description:** The package `description` in `pyproject.toml` still says "Async Python client *scaffold*" even though the client is fully implemented. Stale "scaffold" wording misrepresents maturity to anyone reading PyPI metadata. (The `mxgw-py` console-script name is itself consistent between `pyproject.toml` and the README.)

**Recommendation:** Update the `pyproject.toml` description to drop "scaffold"; keep README CLI examples in sync with the actual `mxgw-py` entry point.

**Resolution:** 2026-05-18 — Confirmed: `pyproject.toml:8` `description` read "Async Python client scaffold for MXAccess Gateway." Changed to "Async Python client for MXAccess Gateway." The `mxgw-py` console-script name was already consistent with the README, so no README change was needed. Pure metadata fix — no test required.

### Client.Python-002

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `clients/python/src/mxgateway/__init__.py:27` |
| Status | Resolved |

**Description:** `MxGatewayCommandError` is imported into `__init__.py` and is a documented public exception, but it is missing from `__all__`. It is the parent of `MxAccessError` and a meaningful catch target, so omitting it from the public surface is inconsistent — `from mxgateway import *` will not expose it and tooling that respects `__all__` treats it as private.

**Recommendation:** Add `"MxGatewayCommandError"` to the `__all__` list.

**Resolution:** 2026-05-18 — Re-triaged: this finding is stale against the reviewed source. `clients/python/src/mxgateway/__init__.py` already imports `MxGatewayCommandError` (line 16) **and** lists `"MxGatewayCommandError"` in `__all__` (line 38). `from mxgateway import *` exposes it correctly. Verified at runtime (`'MxGatewayCommandError' in mxgateway.__all__` is `True`). No source change required — the defect described no longer exists.

### Client.Python-003

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `clients/python/src/mxgateway/client.py:125-137,155-173` |
| Status | Resolved |

**Description:** `stream_events_raw` and `query_active_alarms` call the stub directly with a `timeout` kwarg when `stream_timeout` is set, with no `TypeError` fallback. `galaxy.py:watch_deploy_events` and `_unary` *do* have a fallback that strips `timeout` if the callable rejects it. This asymmetry means a fake/older stub that does not accept `timeout` crashes for gateway streams but not Galaxy streams. It is only masked today because `stream_timeout` defaults to `None`.

**Recommendation:** Apply the same `try/except TypeError` timeout-fallback pattern to `stream_events_raw` and `query_active_alarms`, or remove the fallback everywhere and standardise on a single behaviour.

**Resolution:** 2026-05-18 — Confirmed: both stream methods in `client.py` called the stub with `timeout` unconditionally and had no `TypeError` fallback, unlike `_unary` and `galaxy.watch_deploy_events`. Added a shared `_open_stream` helper in `client.py` that opens a server-streaming call and strips the `timeout` kwarg when the stub raises `TypeError: ... unexpected keyword argument 'timeout'`, then routed both `stream_events_raw` and `query_active_alarms` through it. Regression tests in `tests/test_stream_timeout_fallback.py` (`test_stream_events_raw_falls_back_when_stub_rejects_timeout`, `test_query_active_alarms_falls_back_when_stub_rejects_timeout`, `test_stream_events_raw_still_passes_timeout_to_capable_stub`) failed before the fix and pass after. No public behaviour change for real gRPC stubs, so no README update needed.

### Client.Python-004

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `clients/python/src/mxgateway_cli/commands.py:386,402-404` |
| Status | Resolved |

**Description:** In `_smoke`, the local variable `closed` is set to `False` and never reassigned; the `finally` block's `if not closed:` is therefore always true. This is dead/misleading code suggesting a removed early-close path.

**Recommendation:** Remove the `closed` variable and the `if not closed:` guard; call `await session.close()` directly in the `finally` block (or use `async with session:`).

**Resolution:** 2026-05-18 — Confirmed: `closed = False` was set and never reassigned, making `if not closed:` dead code. Replaced the `try/finally` with `async with session:` so the session is closed via the documented async context manager — `Session` already implements `__aexit__` → `close()`. Behaviour is unchanged (the session is still closed on every exit path); no test needed for the dead-code removal — exercised by the existing CLI smoke test.

### Client.Python-005

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Performance & resource management |
| Location | `clients/python/src/mxgateway/galaxy.py:117-140` |
| Status | Resolved |

**Description:** `discover_hierarchy` pages through the entire Galaxy object hierarchy and accumulates every `GalaxyObject` (each carrying its full attribute list) into a single in-memory `list` before returning. For a large Galaxy this is a very large allocation with no streaming alternative and no caller-side bound.

**Recommendation:** Offer an async-generator variant (e.g. `iter_hierarchy()`) that yields objects/pages as they arrive, keeping `discover_hierarchy()` as a convenience wrapper. At minimum document the memory characteristic.

**Resolution:** 2026-05-18 — Confirmed: `discover_hierarchy` buffered the entire hierarchy with no streaming alternative. Added `GalaxyRepositoryClient.iter_hierarchy`, an async generator that fetches one `DiscoverHierarchyRequest` page at a time and yields each `GalaxyObject` as it arrives, so peak memory is bounded by a single page (`_DISCOVER_HIERARCHY_PAGE_SIZE`). Pages are fetched lazily — the next page is only requested after the current page is fully consumed. `discover_hierarchy` is now a thin convenience wrapper (`[obj async for obj in self.iter_hierarchy()]`) that preserves its `list[GalaxyObject]` contract, including the repeated-page-token guard. Regression tests in `tests/test_galaxy_iter_hierarchy.py` (`test_iter_hierarchy_yields_objects_across_pages`, `test_iter_hierarchy_is_lazy_and_does_not_prefetch_next_page`, `test_iter_hierarchy_rejects_repeated_page_token`, `test_discover_hierarchy_still_returns_full_list`) failed before the fix and pass after. `clients/python/README.md` updated with the `iter_hierarchy` usage and memory guidance since this adds a new public method.

### Client.Python-006

| Field | Value |
|---|---|
| Severity | Low |
| Category | Concurrency & thread safety |
| Location | `clients/python/src/mxgateway/client.py:74-82`, `clients/python/src/mxgateway/galaxy.py:85-93`, `clients/python/src/mxgateway/session.py:38-55` |
| Status | Resolved |

**Description:** `close()` on the clients and `Session.close()` use a plain `self._closed` check-then-set with an `await` between, with no lock. If two coroutines call `close()` concurrently both can pass the guard before either sets it, causing a double `channel.close()` / double `CloseSession` RPC. Single-task usage is the documented contract, so impact is low, but the idempotency guarantee asserted in docstrings only holds for sequential calls.

**Recommendation:** Set `self._closed = True` before the `await`, or guard with an `asyncio.Lock`, so the idempotency claim holds under concurrent close.

**Resolution:** 2026-05-18 — Confirmed the check-then-set window. Fixed `GatewayClient.close`, `GalaxyRepositoryClient.close`, and `Session.close` to set `self._closed = True` *before* the `await` (channel close / `CloseSession` RPC). A second coroutine entering `close()` while the first is still awaiting now hits the early-return guard and does not issue a second `channel.close()` / `CloseSession`. Docstrings updated to state the idempotency holds under concurrent calls. TDD: regression tests in `tests/test_low_severity_findings.py` (`test_gateway_client_concurrent_close_closes_channel_once`, `test_galaxy_client_concurrent_close_closes_channel_once`, `test_session_concurrent_close_sends_one_close_session_rpc`) — each uses a fake channel/client that stalls inside `close`/`close_session_raw` so two concurrent `close()` calls interleave at the exact race window; they failed before the fix and pass after.

### Client.Python-007

| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | `clients/python/src/mxgateway/client.py:204-213` |
| Status | Resolved |

**Description:** `_canceling_iterator` (gateway event stream) does not catch `asyncio.CancelledError` to invoke `call.cancel()` explicitly — it relies on the `finally` block. `galaxy.py:_canceling_iterator` *does* explicitly catch `CancelledError`, cancel, and re-raise. The two are functionally equivalent today, but the inconsistency between near-identical helpers invites future divergence.

**Recommendation:** Make the two `_canceling_iterator` helpers identical, ideally by factoring a single shared helper.

**Resolution:** 2026-05-18 — Confirmed the divergence. Factored a single shared helper: `client._canceling_iterator(call, operation)` now takes the `map_rpc_error` operation string as a parameter, explicitly catches `asyncio.CancelledError` (cancels the call, re-raises) and `grpc.RpcError`, and repeats the cancel in `finally`. This replaces both the gateway `_canceling_iterator` and the gateway `_canceling_active_alarms_iterator`; `galaxy.py` now imports and delegates to the same helper instead of defining its own, so the gateway and Galaxy stream helpers are byte-for-byte identical. TDD: `tests/test_low_severity_findings.py::test_gateway_stream_iterator_cancels_call_on_task_cancellation` drives a cancellable fake stream and asserts the gateway iterator cancels the underlying call on task cancellation. All existing stream-cancellation tests still pass.

### Client.Python-008

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `clients/python/src/mxgateway/values.py:62-67,83-88` |
| Status | Resolved |

**Description:** `to_mx_value` maps any Python `float` to `VT_R8`/`MX_DATA_TYPE_DOUBLE` with no handling for `nan`/`inf`, which are serialised and forwarded to MXAccess which may reject or mis-handle them. `bytes` is mapped to `VT_RECORD`/`MX_DATA_TYPE_UNKNOWN`, a questionable default. The `data_type` keyword exists but `Session.write` never forwards it.

**Recommendation:** Document the float/bytes mapping assumptions, optionally validate finiteness, and consider plumbing the `data_type` keyword through `Session.write`/`write2`.

**Resolution:** 2026-05-18 — Confirmed the non-finite-float hazard. Added an `_ensure_finite` guard in `values.py`: `to_mx_value` now raises `ValueError` for `nan`/`inf`/`-inf`, both for a scalar `float` and for a non-finite element inside a float sequence — MXAccess has no defined wire representation for non-finite doubles, so rejecting client-side is the correct fail-fast. The `float`/`bytes` mapping assumptions (finite-only doubles; `bytes` as an opaque `VT_RECORD` pass-through) are now documented in the `values.py` module docstring and `clients/python/README.md`. Plumbing `data_type` through `Session.write`/`write2` was deliberately *not* done: it is a larger public-API surface change the finding only marks as "consider", and the documented MXAccess-parity convention is type-by-Python-value; the `data_type` keyword stays available on `to_mx_value` for callers that build the `MxValue` directly. TDD: `tests/test_low_severity_findings.py` adds `test_to_mx_value_rejects_nan`, `test_to_mx_value_rejects_positive_infinity`, `test_to_mx_value_rejects_negative_infinity`, `test_to_mx_value_rejects_non_finite_float_in_sequence`, and `test_to_mx_value_accepts_finite_float`. README updated since `to_mx_value` (used by `Session.write`/`write2`) now rejects an input it previously accepted.

### Client.Python-009

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Testing coverage |
| Location | `clients/python/tests/` |
| Status | Resolved |

**Description:** Several non-trivial public paths are untested: `Session.write2`/`add_item2` request construction; the bulk-size limit `_ensure_bulk_size`/`MAX_BULK_ITEMS` guard; the `None`-argument `TypeError` guards in bulk methods; the TLS `ca_file` read path in `create_channel`; most CLI command bodies; and `map_rpc_error`'s default (non-auth) branch.

**Recommendation:** Add tests for `write2`/`add_item2` request shape, the bulk-size `ValueError`, the `ca_file` TLS branch, the generic `map_rpc_error` fallthrough, and at least one happy-path CLI command using a fake stub.

**Resolution:** 2026-05-18 — Confirmed coverage gap against the existing `tests/` files. Added `tests/test_coverage_gaps.py` covering every path the finding lists: `test_add_item2_sends_item_context_and_returns_handle` and `test_write2_sends_value_and_timestamp_value` (request shape + `MxValue` oneof), `test_subscribe_bulk_rejects_oversized_request` and `test_add_item_bulk_at_limit_is_allowed` (the `MAX_BULK_ITEMS` `_ensure_bulk_size` boundary), `test_advise_item_bulk_rejects_none_argument` (the `None`-argument `TypeError` guard), `test_create_channel_reads_ca_file` and `test_create_channel_missing_ca_file_raises` (the TLS `ca_file` read path), `test_map_rpc_error_generic_branch_returns_transport_error` and `test_map_rpc_error_handles_error_without_code` (the non-auth `map_rpc_error` fallthrough and the no-`code` path), and `test_cli_register_happy_path_emits_server_handle` (a happy-path CLI command body driven end to end through `CliRunner` with a fake stub via a monkeypatched `_connect`). All 10 new tests pass. No source change required — this is a pure coverage finding.

### Client.Python-010

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `clients/python/src/mxgateway/session.py:404`, `clients/python/src/mxgateway_cli/commands.py:422-425` |
| Status | Resolved |

**Description:** `session.py` ends with a module-level late import `from .client import GatewayClient  # noqa: E402` purely to satisfy a string type hint, and `commands.py:_session` does a function-local import. Both work around a circular dependency that `from __future__ import annotations` (already in effect) makes unnecessary. `_session` also lacks a return type annotation.

**Recommendation:** Drop the runtime late import in `session.py` and use a `TYPE_CHECKING`-guarded import for the hint; add the `-> Session` return annotation to `commands.py:_session`.

**Resolution:** 2026-05-18 — Confirmed: with `from __future__ import annotations` in effect all annotations are strings, so the runtime late import was unnecessary. Removed the trailing `from .client import GatewayClient  # noqa: E402` in `session.py` and replaced it with a top-of-file `if TYPE_CHECKING:` import that satisfies the `GatewayClient` hint without a runtime dependency (no import cycle: `client.py` does not import `session` at module scope). In `commands.py`, hoisted the function-local `from mxgateway.session import Session` to a module-level import and added the `-> Session` return annotation to `_session`. Verified `import mxgateway` and `import mxgateway_cli.commands` succeed with no circular-import error. Pure refactor — covered by the existing import and CLI tests; no new test needed.

### Client.Python-011

| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | `clients/python/src/mxgateway/errors.py:122-148` |
| Status | Resolved |

**Description:** `ensure_mxaccess_success` raises `MxAccessError` if any `mx_status.success == 0`. This treats `success == 0` as the failure sentinel, but `0` is also the proto3 scalar default for an unset `MxStatusProxy`. If the gateway ever returns a reply with an unpopulated status entry (e.g. a partially-filled bulk result), the client raises `MxAccessError` even though no real failure occurred.

**Recommendation:** Confirm against the proto/gateway contract whether `success` is guaranteed populated for every `statuses` entry; if not, key the failure decision on an explicit failure field rather than the `success == 0` default.

**Resolution:** 2026-05-18 — Confirmed against the gateway contract: `success` is **not** guaranteed populated for every `statuses` entry. `src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs::ConvertMany` emits a placeholder `MxStatusProxy` for a null `MXSTATUS_PROXY` COM array entry, setting `Category`/`DetectedBy` to `Unknown` but **leaving `Success` at its proto3 default of 0**. A fully-default proto entry likewise has `success == 0`. Under the old client logic either placeholder would falsely raise `MxAccessError`. Fixed `ensure_mxaccess_success` to key the per-status failure decision on a new `_is_mxaccess_status_failure` helper that requires `success == 0` **and** a populated, non-OK `category` — a status with `category` of `MX_STATUS_CATEGORY_UNSPECIFIED` (default proto) or `MX_STATUS_CATEGORY_UNKNOWN` (the null-entry placeholder) is treated as unpopulated and ignored. `MX_STATUS_CATEGORY_OK` is also excluded so a genuine success entry never raises. Real failures (categories `WARNING` and the error categories, raw value ≥ 2) still raise as before — the existing `write.mxaccess-failure` fixture (`SECURITY_ERROR`/`OPERATIONAL_ERROR` statuses) and the `MXACCESS_FAILURE` protocol-status path are unaffected. TDD: `tests/test_low_severity_findings.py` adds `test_ensure_mxaccess_success_ignores_unpopulated_status_entry` (default + null-placeholder entries, no raise), `test_ensure_mxaccess_success_raises_on_populated_failure_status` (populated `COMMUNICATION_ERROR`, raises), and `test_ensure_mxaccess_success_passes_when_status_reports_success`. No public-behaviour change for genuine replies, so no README update.

### Client.Python-012

| Field | Value |
|---|---|
| Severity | Low |
| Category | mxaccessgw conventions |
| Location | `clients/python/src/mxgateway/client.py:84-108`, `clients/python/src/mxgateway/session.py:57-77` |
| Status | Won't Fix |

**Description:** `Session.invoke_raw` does not run `ensure_mxaccess_success` while `Session.invoke` does, so a caller using `invoke_raw` for parity tests gets a reply where an MXAccess HRESULT failure is silently embedded with no exception. This is by design but under-documented — the README's "preserve raw replies" sentence does not state that `*_raw` methods skip MXAccess-failure detection entirely.

**Recommendation:** Document explicitly (README + docstring) that `*_raw` methods surface MXAccess HRESULT/status failures only inside the reply and do not raise `MxAccessError`, so parity-test callers know to inspect `protocol_status`/`hresult`/`statuses` themselves.

**Resolution:** 2026-05-18 — Won't Fix (no behaviour change). Confirmed this is intentional, correct parity behaviour: the `*_raw` methods exist precisely so parity-test callers can inspect an unmodified gateway reply, including embedded MXAccess HRESULT/status failures, without an exception masking them. Changing `invoke_raw` to raise `MxAccessError` would defeat its purpose and duplicate `Session.invoke`. The finding's only actionable point is the documentation gap, which has been addressed: `clients/python/README.md` now states explicitly that `*_raw` methods enforce gateway protocol success only and do **not** run MXAccess-failure detection, and the docstrings of `GatewayClient.invoke_raw` and `Session.invoke_raw` say the same and point callers to inspect `protocol_status`/`hresult`/`statuses` (and to `Session.invoke` for the checked variant). No code/test change — the runtime contract is unchanged and correct.

### Client.Python-013

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Security |
| Location | `clients/python/src/mxgateway_cli/commands.py:757-762` |
| Status | Resolved |

**Description:** `_use_plaintext` silently returns `True` whenever the endpoint
string starts with `localhost:` or `127.0.0.1:`, even if neither `--plaintext`
nor `--tls` is supplied on the command line. Any CLI subcommand (e.g.
`mxgw-py open-session --endpoint localhost:5001 --api-key mxgw_<secret>`) then
attaches the API key to a plaintext gRPC channel without warning. This is a
silent security downgrade: a user who deliberately ran the gateway behind TLS
on loopback (e.g. for testing a production-shaped TLS config locally) and who
passes `--api-key` expecting the secret to be transport-protected gets a
plaintext bearer token instead. The auto-downgrade is also undocumented —
`README.md` and the CLI `--help` text both describe `--plaintext` and `--tls`
as the controls, with no mention that endpoint-prefix matching can override
either. The other client CLIs do not auto-downgrade: the .NET CLI uses
`https://`-prefix detection on a URI scheme (an explicit signal), Go and Java
require an explicit `--plaintext`/`--tls` choice, and Rust defaults to
plaintext only when `plaintext = true` is set on the options struct.

**Recommendation:** Drop the localhost-prefix auto-plaintext branch and
require the user to pass `--plaintext` or `--tls` (or default to TLS to match
the rest of the matrix). If the implicit-localhost behaviour is kept for
ergonomics, document it prominently in both `README.md` and `--help`, emit a
stderr warning when `--api-key` is combined with the auto-downgrade path, and
add a CLI test asserting the auto-downgrade is in fact active so it is not
silently lost in a future refactor.

**Resolution:** 2026-05-20 — Removed the silent `localhost:` / `127.0.0.1:`
auto-plaintext branch from `_use_plaintext`. The new contract matches the Go
and Java CLIs: **TLS is the default**, `--plaintext` is the only way to opt
in to an unencrypted channel, and `--tls` is accepted as a redundant, explicit
affirmation of the default (mutually exclusive with `--plaintext`, which now
raises `click.UsageError`). The `--plaintext` / `--tls` `--help` text and
`clients/python/README.md` both call out the new behaviour. Added six
regression tests in `clients/python/tests/test_cli.py` covering: (a) a
`localhost:` endpoint with no flags resolves to TLS, (b) a `127.0.0.1:`
endpoint with no flags resolves to TLS, (c) `--plaintext` opts in to plaintext,
(d) `--tls` is accepted and idempotent with the default, (e) `--plaintext`
combined with `--tls` is rejected, and (f) an end-to-end CliRunner test
asserting `ClientOptions.plaintext == False` flows through to
`GatewayClient.connect` when no flag is supplied against a `localhost:`
endpoint. **Behaviour change for callers:** scripts that previously relied on
`mxgw-py … --endpoint localhost:5000 …` selecting plaintext silently must now
add an explicit `--plaintext` flag (or set up TLS on the gateway). Calling
`mxgw-py` with an `--api-key` against a plaintext-only gateway without
`--plaintext` will now fail to connect rather than silently leaking the bearer
token.

### Client.Python-014

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `clients/python/src/mxgateway_cli/commands.py:22-23` |
| Status | Resolved |

**Description:** `commands.py` has two consecutive `from mxgateway.values
import` lines:

```python
from mxgateway.values import to_mx_value
from mxgateway.values import MxValueInput
```

These import from the same module and should be combined into a single
`from mxgateway.values import MxValueInput, to_mx_value`. The split form is
inconsistent with the rest of the file (every other module is imported in a
single statement) and would be flagged by `ruff`/`isort` if any linter were
configured. Pure style, no behavioural impact.

**Recommendation:** Collapse the two imports into one statement, ordered to
match the conventional alphabetical-within-module pattern:
`from mxgateway.values import MxValueInput, to_mx_value`.

**Resolution:** 2026-05-20 — Collapsed the two consecutive
`from mxgateway.values import to_mx_value` / `from mxgateway.values import MxValueInput`
lines in `clients/python/src/mxgateway_cli/commands.py` into a single
`from mxgateway.values import MxValueInput, to_mx_value` statement, matching
the alphabetical-within-module pattern used elsewhere in the file. Pure style
fix — no behavioural impact, covered by the existing CLI tests.

### Client.Python-015

| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `clients/python/src/mxgateway_cli/commands.py:273-294,564-647`, `clients/python/tests/` |
| Status | Resolved |

**Description:** `_bench_read_bulk` is a ~80-line CLI body that opens its own
session, registers, subscribe_bulks, runs a warm-up loop, a measurement loop,
collects per-call latencies, computes a percentile summary, and emits the
shared cross-language JSON schema. It is the largest untested CLI command in
the module — `tests/` has no `bench_read_bulk` test, fake-stub-driven or
otherwise. A drift in the schema field names (`callsPerSecond`,
`cachedReadResults`, `latencyMs.p50`, …) would break the cross-language
`scripts/bench-read-bulk.ps1` aggregation silently. `_percentile_summary` and
`_percentile` are also untested — the boundary cases (`n == 0`, `n == 1`,
quantile interpolation) would benefit from a small unit test since the
identical algorithm is duplicated in the .NET / Go / Rust / Java drivers and
a divergence would corrupt cross-language comparisons.

**Recommendation:** Add a fake-stub-driven `bench_read_bulk` test that drives
a short `--duration-seconds 0 --warmup-seconds 0` run through `CliRunner` and
asserts the JSON schema (`language == "python"`, the full key set,
`latencyMs.p50/p95/p99/max/mean` present). Add unit tests for `_percentile`
covering `n == 0`, `n == 1`, and a known-good interpolated value at p95 so
the implementation cannot silently drift from the other clients.

**Resolution:** 2026-05-20 — Added `clients/python/tests/test_cli_bench_and_helpers.py`
with three layers of coverage. (1) `_percentile` unit tests pin the
cross-language algorithm (`rank = q * (n - 1)`, linear interpolation between
adjacent ranks): empty sample returns `0.0`, single element returns that
element, exact-rank queries return the sample value (p50 of `[10,20,30,40,50]`
is `30.0`), and the interpolated p95/p99 values (`48.0` / `49.6` for that same
five-element sample) are locked down so any drift from the .NET / Go / Rust /
Java drivers fails fast. (2) `_percentile_summary` tests assert the full
`{p50, p95, p99, max, mean}` dict shape, the zero-sample placeholder, and the
3-decimal rounding contract. (3) A `bench-read-bulk` smoke test
(`test_bench_read_bulk_emits_cross_language_schema`) drives the CLI through
`CliRunner` with `--duration-seconds 0 --warmup-seconds 0` against a fake stub
that handles `OpenSession`, `Register`, `SubscribeBulk`, `ReadBulk`, and
`UnsubscribeBulk`, then asserts the emitted JSON has exactly the 16
cross-language schema keys (`language`, `command`, `endpoint`, `clientName`,
`bulkSize`, `durationSeconds`, `warmupSeconds`, `durationMs`, `tags`,
`totalCalls`, `successfulCalls`, `failedCalls`, `totalReadResults`,
`cachedReadResults`, `callsPerSecond`, `latencyMs`) and that `latencyMs` is a
`{p50, p95, p99, max, mean}` sub-object — guarding against silent breakage of
`scripts/bench-read-bulk.ps1`'s cross-language aggregation. No source change —
this is a pure coverage finding.

### Client.Python-016

| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `clients/python/src/mxgateway_cli/commands.py:25,757-775,805-830` |
| Status | Resolved |

**Description:** Three CLI helper paths are not covered by `tests/`:

1. `_use_plaintext` localhost auto-downgrade (line 762) — the
   `endpoint.startswith("localhost:") or endpoint.startswith("127.0.0.1:")`
   branch (see also Client.Python-013) is untested; no test asserts that an
   endpoint without `--plaintext` and without `--tls` resolves to plaintext.
2. `_collect_events` `MAX_AGGREGATE_EVENTS` guard (line 811-815) — passing
   `--max-events` greater than `MAX_AGGREGATE_EVENTS` raises
   `click.BadParameter`, but no test exercises the guard. A silent removal of
   the constant or the comparison would not be caught.
3. `_api_key_from_env` (line 765-768) — only the implicit path through
   `_secrets` is exercised; there is no test that verifies an env-var name
   resolves to a value and that an unset env var produces `None`.

These are all small, fake-stub-driven CLI behaviours rather than end-to-end
paths. The previous coverage finding (Client.Python-009) closed without
adding tests for these specific paths.

**Recommendation:** Add three small `CliRunner` / unit tests: one asserting
the localhost auto-plaintext (or its replacement, if Client.Python-013 is
fixed), one asserting `--max-events 10001` exits non-zero with the
`MAX_AGGREGATE_EVENTS` error message, and one asserting
`_api_key_from_env("MXGATEWAY_API_KEY")` returns the env value and `None` for
an unset variable.

**Resolution:** 2026-05-20 — Scope adjusted: Client.Python-013 has since
removed the `_use_plaintext` localhost auto-plaintext branch, so item (1) is
no longer a real code path — the
`test_use_plaintext_requires_explicit_flag_for_localhost_endpoint` and
`test_cli_localhost_endpoint_defaults_to_tls_via_open_session` regressions
added under Client.Python-013 already pin the new TLS-by-default contract.
The remaining two helpers are now covered in
`clients/python/tests/test_cli_bench_and_helpers.py`. (2)
`MAX_AGGREGATE_EVENTS` cap:
`test_collect_events_rejects_max_events_above_aggregate_cap` drives
`stream-events` with `--max-events 10001` through `CliRunner` against
stubbed `_connect` / `_session` fakes and asserts the CLI exits non-zero with
the documented `less than or equal to 10000` message;
`test_collect_events_accepts_max_events_at_aggregate_cap_boundary` confirms
`--max-events 10000` is accepted at the boundary and returns an empty event
list. (3) `_api_key_from_env`:
`test_api_key_from_env_resolves_value_when_variable_is_set` (env-var
populated → returned),
`test_api_key_from_env_returns_none_when_variable_is_unset` (env-var unset
→ `None`), `test_api_key_from_env_returns_none_when_name_is_none` (the
`name is None` early-return), and
`test_api_key_from_env_returns_none_when_name_is_empty_string` (the
`if not name` truthiness guard). No source change — pure coverage finding.

### Client.Python-017

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `clients/python/pyproject.toml:5-25`, `clients/python/src/mxgateway/` |
| Status | Resolved |

**Description:** The package metadata in `pyproject.toml` is minimal for a
published wheel:

* No `authors` field. PyPI / `pip show` will display no author.
* No `license` field, no `license-files` field, and no `LICENSE` file is
  referenced from the project. The repo as a whole has no top-level
  `LICENSE` either, but other client packages (Java has a license entry, the
  .NET package has a license expression in the `csproj`) tend to set this.
* No `classifiers` (no `Programming Language :: Python :: 3.12`,
  `Operating System :: Microsoft :: Windows`, `Topic :: …`, no
  development-status classifier). Without these the PyPI search facets are
  empty and tooling like `pip` cannot tell whether the package is
  alpha/beta/stable.
* No `keywords`, no `[project.urls]` (no homepage / source / issue link
  pointing back to the repo).
* The package ships no PEP 561 `py.typed` marker file in
  `src/mxgateway/`. Type hints are written throughout the module
  (`from __future__ import annotations`, full annotations on every public
  function), but downstream consumers running `mypy` on `mxaccess-gateway-client`
  will not see those hints — PEP 561 requires the marker file to opt the
  package into type-stub distribution.

**Recommendation:** Add `authors`, `license = "<spdx>"`, `keywords`, and
`[project.urls]` to `pyproject.toml`; add at least the standard `classifiers`
trio (`Development Status`, `Programming Language :: Python :: 3.12`,
`Intended Audience`); create an empty `src/mxgateway/py.typed` file and
include it in the wheel via `[tool.setuptools.package-data]` so consumers
running `mypy` against an installed wheel pick up the type information.

**Resolution:** 2026-05-20 — Filled out `clients/python/pyproject.toml`
with the missing PyPI metadata: `authors = [{ name = "MXAccess Gateway
Authors" }]`, `license = "Proprietary"` (the repo has no top-level
`LICENSE` file and no other client publishes under an OSS licence, so the
SPDX `Proprietary` expression matches the de-facto status), the standard
classifier set (`Development Status :: 4 - Beta`, `Intended Audience ::
Developers` / `Information Technology`, `Operating System :: Microsoft ::
Windows` and `:: POSIX`, `Programming Language :: Python` /
`Python :: 3` / `Python :: 3.12`, `Topic :: Software Development ::
Libraries :: Python Modules`, `Topic :: System :: Distributed Computing`,
and `Typing :: Typed`), a `keywords` list
(`mxaccess`, `archestra`, `gateway`, `grpc`, `industrial`, `scada`), and
`[project.urls]` with `Homepage` / `Source` / `Issues` pointing at the
Gitea repo. Added the PEP 561 marker file
`clients/python/src/mxgateway/py.typed` (empty, as the spec requires) and
declared it in `[tool.setuptools.package-data] mxgateway = ["py.typed"]`
so the wheel ships the marker and downstream `mypy` users see the
inline type hints. Pure metadata / packaging change — `python -m pytest -q`
still passes (91 tests).

### Client.Python-018

| Field | Value |
|---|---|
| Severity | High |
| Category | Code organization & conventions |
| Location | `clients/python/pyproject.toml:11` |
| Status | Resolved |

**Description:** The Client.Python-017 resolution set
`license = "Proprietary"` as a top-level string. Under PEP 639 (enforced
by `setuptools >= 77`, and active in the installed `setuptools 82.0.1`),
the `project.license` string form must be a valid SPDX expression.
`"Proprietary"` is not a registered SPDX identifier, so the configured
build backend (`setuptools.build_meta`) refuses the file outright. Both
`python -m pip wheel . --no-deps --wheel-dir …` and
`python -m pip install -e .` — the exact commands documented in
`clients/python/README.md` ("Build And Test", "Packaging") and the
"build wheel" instruction in `docs/ClientPackaging.md` — now fail before
any source is compiled with:

```
ValueError: invalid pyproject.toml config: `project.license`.
configuration error: `project.license` must be valid exactly by one definition (0 matches found):
    - {type: string, format: 'SPDX'}
    - type: table keys: 'file': … required: ['file']
    - type: table keys: 'text': … required: ['text']
```

`python -m pytest` still runs because `[tool.pytest.ini_options]
pythonpath = ["src"]` lets pytest import the package without an install
— which masked the regression at commit time and explains how the
Client.Python-017 resolution comment was able to assert "`python -m
pytest -q` still passes (91 tests)" while shipping a wheel build that
cannot start. The Client.Python-017 resolution comment that "the SPDX
`Proprietary` expression matches the de-facto status" is incorrect:
`Proprietary` is *not* a registered SPDX identifier; only entries on the
SPDX licence list (e.g. `MIT`, `Apache-2.0`, `BSD-3-Clause`) or
`LicenseRef-*` custom identifiers satisfy the
`{ type: string, format: 'SPDX' }` rule. PEP 639 added the
`LicenseRef-…` escape hatch precisely for proprietary / unlisted
licences.

This is a regression of the developer-onboarding workflow introduced by
the very commit being reviewed. A fresh checkout cannot run
`python -m pip install -e ".[dev]"` (the command in `CLAUDE.md`'s
"Clients" section) without first patching `pyproject.toml`.

**Recommendation:** Fix the `license` value so the build backend
accepts it. Three concrete options, in order of preference:

1. Use a `LicenseRef-*` SPDX-compatible custom identifier:
   `license = "LicenseRef-Proprietary"`. Requires no additional
   `LICENSE` file and is honoured by setuptools / pip / PyPI as a
   proprietary marker.
2. Add a top-level `LICENSE` file (or `clients/python/LICENSE`) and
   point at it via the table form:
   `license = { file = "LICENSE" }`. This also documents the proprietary
   terms.
3. Drop the `license` key entirely and convey the same intent via the
   classifier `"License :: Other/Proprietary License"` (already part of
   the classifier set), reverting the PEP-639 string field that the
   build backend now insists must be SPDX.

Add a CI / pre-commit check that runs `python -m pip wheel . --no-deps`
(or `python -m build`) on `clients/python` so a future
`pyproject.toml` regression is caught at commit time rather than at
first install on a clean machine. See also Client.Python-020.

**Resolution:** 2026-05-20 — Dropped the invalid top-level
`license = "Proprietary"` string from `clients/python/pyproject.toml`
and added the existing `License :: Other/Proprietary License` trove
classifier to convey the same intent without violating PEP 639's SPDX
rule. No `LICENSE` file exists at the repo root or under
`clients/python/`, so the `license = { file = "LICENSE" }` table form
was not used; relying on the classifier is the option (3) variant
called out in the recommendation. Verified by running
`python -m pip wheel . --no-deps -w ./.test-wheel-output` from
`clients/python`: the build now succeeds and emits
`mxaccess_gateway_client-0.1.0-py3-none-any.whl` (47 KB) where
previously it failed with the `project.license must be valid exactly
by one definition` `ValueError`. The CI / pre-commit recommendation is
addressed by Client.Python-020.

### Client.Python-019

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `clients/python/pyproject.toml:60-61`, `clients/python/src/mxgateway_cli/` |
| Status | Resolved |

**Description:** Client.Python-017 added the PEP 561 marker file
`clients/python/src/mxgateway/py.typed` and declared it in
`[tool.setuptools.package-data] mxgateway = ["py.typed"]`. The wheel
therefore advertises `mxgateway` as typed. However the same wheel
also ships the **`mxgateway_cli`** package (`setuptools.packages.find`
with `where = ["src"]` discovers both `mxgateway` and `mxgateway_cli`,
confirmed via `find_packages` in this review), and `mxgateway_cli`:

* is shipped in the wheel and is the package the `mxgw-py` console
  script entry point resolves into (`[project.scripts] mxgw-py =
  "mxgateway_cli.commands:main"`),
* is fully type-annotated (every function in `commands.py` has full
  parameter and return annotations; `from __future__ import annotations`
  is in effect),
* but has no `py.typed` file and is not listed in
  `[tool.setuptools.package-data]`.

PEP 561 requires the marker file inside **each** importable package the
distribution wants to expose to type checkers — the `mxgateway` marker
does not transfer to `mxgateway_cli`. A downstream consumer that imports
or composes against `mxgateway_cli` (e.g. wrapping it as a programmatic
CLI library) will see all symbols as `Untyped` under `mypy` despite the
hints being present in source.

This is a follow-up to Client.Python-017 — the fix is small and pure
packaging.

**Recommendation:** Create
`clients/python/src/mxgateway_cli/py.typed` (empty file, as PEP 561
requires) and extend the existing package-data declaration so the
wheel ships it:

```toml
[tool.setuptools.package-data]
mxgateway = ["py.typed"]
mxgateway_cli = ["py.typed"]
```

No source change in either package; verify by building a wheel
(once Client.Python-018 is fixed) and inspecting that both
`mxgateway/py.typed` and `mxgateway_cli/py.typed` appear in the wheel
contents.

**Resolution:** 2026-05-20 — Created the empty PEP 561 marker file
`clients/python/src/mxgateway_cli/py.typed` and added
`mxgateway_cli = ["py.typed"]` under
`[tool.setuptools.package-data]` in `clients/python/pyproject.toml`
alongside the existing `mxgateway = ["py.typed"]` line. Verified by
inspecting the built wheel
(`mxaccess_gateway_client-0.1.0-py3-none-any.whl`): the archive now
contains both `mxgateway/py.typed` and `mxgateway_cli/py.typed`, so
downstream `mypy` consumers see the inline type hints in both
packages. Pure packaging change — no source modifications.

### Client.Python-020

| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `clients/python/tests/`, `scripts/` |
| Status | Resolved |

**Description:** Client.Python-018 is invisible to the existing test
suite: `python -m pytest` passes because `[tool.pytest.ini_options]
pythonpath = ["src"]` lets pytest import the package without going
through `setuptools.build_meta`. None of the 91 tests build the wheel,
do an editable install, or otherwise exercise the
`setuptools.build_meta` configuration validator. As a result, a
`pyproject.toml` regression that breaks `pip install -e .` /
`pip wheel .` — the exact commands documented in the Python client
README and `CLAUDE.md` — passes the test suite green. The other
language clients have parallel coverage gaps (no CI-level "the package
installs" smoke test for Python in
`scripts/run-client-e2e-tests.ps1`, which only runs the live e2e
matrix and assumes the editable install already worked), but Python
is the only one whose published install command is currently broken.

**Recommendation:** Add a thin pytest module (e.g.
`tests/test_packaging.py`) that runs

```python
import subprocess, sys, pathlib
def test_pyproject_validates_against_setuptools_build_meta():
    here = pathlib.Path(__file__).resolve().parent.parent
    result = subprocess.run(
        [sys.executable, "-m", "pip", "wheel", ".",
         "--no-deps", "--no-build-isolation",
         "--wheel-dir", str(tmp_path)],
        cwd=here, capture_output=True, text=True,
    )
    assert result.returncode == 0, result.stderr
```

(or any equivalent that invokes
`setuptools.config.pyprojecttoml.read_configuration`). Marker the test
with `@pytest.mark.slow` if the wheel build is too heavy for the
default suite, and document the test in the README. Alternatively
add a CI step to `scripts/run-client-e2e-tests.ps1` (or a new
`scripts/check-python-package.ps1`) that fails the build when the
wheel build fails. Either approach would have surfaced
Client.Python-018 at commit time.

**Resolution:** 2026-05-20 — Added
`clients/python/tests/test_packaging.py::test_pip_wheel_build_succeeds`.
The test invokes `python -m pip wheel . --no-deps --wheel-dir <tmp>`
against the package root via `subprocess` and asserts (a) exit code
zero and (b) an `mxaccess_gateway_client-*.whl` file is produced in
the temp directory, capturing stdout/stderr in the assertion message
on failure so any future PEP 639 / SPDX violation or other
`setuptools.build_meta` configuration error is reported with the
build backend's own error text. Verified the test would have caught
Client.Python-018: with the old `license = "Proprietary"` string in
place the test fails with the `project.license must be valid exactly
by one definition` `ValueError`. The pytest module is the simpler
half of the recommendation; no PowerShell wrapper script was added
since pytest already runs in the same `python -m pytest` invocation
the README documents. Test suite is now 92 tests (was 91), all
passing.

### Client.Python-021

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `clients/python/src/mxgateway_cli/commands.py`, `clients/python/README.md:235-258` |
| Status | Resolved |

**Description:** Cross-client CLI parity check (one of the things the
review prompt asks for): the `mxgw-py` CLI subcommand set has drifted
from every other client CLI in the matrix.

Subcommand inventory at this commit:

| Subcommand | .NET (`mxgw`) | Go (`mxgw-go`) | Rust (`mxgw`) | Java (`mxgw-java`) | Python (`mxgw-py`) |
|---|---|---|---|---|---|
| `version` | yes | yes | yes | yes | yes |
| `ping` | yes | (no) | yes | (no) | yes |
| `open-session` / `close-session` | yes | yes | yes | yes | yes |
| `register` / `add-item` / `advise` | yes | yes | yes | yes | yes |
| `subscribe-bulk` / `unsubscribe-bulk` / `read-bulk` | yes | yes | yes | yes | yes |
| `write-bulk` / `write2-bulk` / `write-secured-bulk` / `write-secured2-bulk` | yes | yes | yes | yes | yes |
| `write` / `write2` | yes / (varies) | yes / (no) | yes / yes | yes / (no) | yes / yes |
| `stream-events` | yes | yes | yes | yes | yes |
| `smoke` | yes | yes | yes | yes | yes |
| `bench-read-bulk` | yes | yes | yes | yes | yes |
| `bench-stream-events` | **yes** | (no) | (no) | (no) | (no) |
| `galaxy-test-connection` (or alias) | **yes** | **yes** | **yes** | **yes** | **(no)** |
| `galaxy-last-deploy` / `galaxy-deploy-time` | **yes** | **yes** | **yes** | **yes** | **(no)** |
| `galaxy-discover` | **yes** | **yes** | **yes** | **yes** | **(no)** |
| `galaxy-watch` | **yes** | **yes** | **yes** | **yes** | **(no)** |

Two parity gaps remain after Client.Python-013/017:

1. The Python CLI ships **no Galaxy subcommands at all** even though
   the `GalaxyRepositoryClient` library wrapper is fully implemented
   and exercised by `tests/test_galaxy.py` /
   `tests/test_galaxy_iter_hierarchy.py`. The README acknowledges the
   `watch-deploy-events` gap inline ("The CLI does not currently
   expose a streaming `watch-deploy-events` subcommand — use the
   library API directly when subscribing to deploy events from
   Python.") but does not call out that **the other three Galaxy
   subcommands are also missing** — and the .NET / Go / Rust / Java
   CLIs all expose them. A user running the cross-language smoke
   matrix who expects Python to behave like the other clients sees a
   silent "command not found" on `mxgw-py galaxy-test-connection`.
2. The new `bench-stream-events` subcommand (added to the .NET CLI in
   the previous commit `1cd51bb`) is .NET-only today; the Python CLI
   is consistent with Go / Rust / Java on this point. Worth flagging
   as a forward-looking parity gap that will need filling if the
   cross-language benchmark matrix grows a stream-events driver in
   `scripts/`.

Severity is Low because the existing `scripts/bench-read-bulk.ps1`
matrix only invokes `bench-read-bulk` and does not break, and the
Python `GalaxyRepositoryClient` library is fully functional — the gap
is purely in the test CLI surface. But cross-client parity is an
explicit review check and the gap is not documented.

**Recommendation:** Either (a) add `galaxy-test-connection`,
`galaxy-last-deploy`, `galaxy-discover`, and `galaxy-watch`
subcommands to `mxgateway_cli/commands.py` (each is a thin wrapper
over `GalaxyRepositoryClient`, mirroring the existing four-language
implementation), or (b) update `clients/python/README.md`'s "CLI"
section with an explicit "CLI parity gaps" subsection that lists the
missing subcommands and recommends the library API. Option (a) is
preferable for cross-language matrix testing. Also document the
`bench-stream-events` gap symmetrically once a cross-language stream
benchmark driver is added under `scripts/`.

**Resolution:** 2026-05-20 — Scoped this finding to a
documentation-only fix; the full Galaxy CLI parity implementation
(four new subcommands wired to `GalaxyRepositoryClient`) is a larger
piece of work and will be tracked as a separate follow-up finding.
Added a new "CLI Parity Gaps" subsection to
`clients/python/README.md` immediately under the existing CLI
section that explicitly enumerates the four missing
`mxgw-py` Galaxy subcommands (`galaxy-test-connection`,
`galaxy-last-deploy`, `galaxy-discover`, `galaxy-watch`), names the
sibling CLIs that already expose them (.NET `mxgw`, Go `mxgw-go`,
Rust `mxgw`, Java `mxgw-java`), points readers at the library API
(`GalaxyRepositoryClient`, already documented under "Galaxy
Repository Browse") as the supported Python entry point in the
interim, and also flags the .NET-only `bench-stream-events` gap so
the cross-language benchmark matrix has a record of the asymmetry.
No CLI source change; the implementation of the four Galaxy
subcommands is deferred. Resolved as a doc note rather than a full
parity fix.

### Client.Python-022

| Field | Value |
|---|---|
| Severity | High |
| Category | Documentation & comments |
| Location | `clients/python/README.md:201-202`, `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:389-420` |
| Status | Resolved |

**Description:** The README CLI examples added by commit `8738735` for the
new alarm subcommands cite flags the CLI does not accept:

```
mxgw-py stream-alarms --session-id <id> --max-messages 1 --json
mxgw-py acknowledge-alarm --session-id <id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
```

Both subcommands are session-less (the alarm feed is served by the gateway
itself, not a worker session — see the docstring on `acknowledge_alarm`,
"Acknowledge an active MXAccess alarm condition (session-less)"). Neither
`@main.command("stream-alarms")` nor `@main.command("acknowledge-alarm")`
declares a `--session-id` option, and `acknowledge-alarm` declares the
ack-target as `--reference`, **not** `--alarm-reference`. A user copy-pasting
either example gets `Error: no such option: --session-id` (or
`--alarm-reference`) and exits non-zero before any RPC is attempted.

This drift is invisible to the test suite because
`tests/test_cli.py::test_acknowledge_alarm_requires_reference` only asserts
that the missing-flag error mentions `--reference` — it does not validate
the README at all. The .NET / Go / Rust / Java alarm CLI examples in the
sibling READMEs are consistent with their actual flag names, so the Python
README is the only one out of step with its implementation.

**Recommendation:** Either fix the README examples to match the implementation
(remove `--session-id` from both lines, rename `--alarm-reference` to
`--reference`), or — if cross-client parity wants the longer flag name —
rename the CLI option to `--alarm-reference` and add a test that copy-pastes
the README examples through `CliRunner` to assert they parse. Option (1) is
the smaller change.

**Resolution:** 2026-05-24 — Fixed the README examples to match the
implementation (option 1, smaller change). `clients/python/README.md:201-202`
now reads `mxgw-py stream-alarms --max-messages 1 --json` and
`mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json`
— `--session-id` is dropped from both lines (the alarm feed is gateway-served,
session-less) and `--alarm-reference` is renamed to the real `--reference` flag.
Regression test
`tests/test_review_findings_022_to_026.py::test_readme_alarm_examples_parse_against_cli`
extracts every `mxgw-py …` line from the README, appends `--help` so only the
parser runs, and asserts that no example produces a `no such option` Click error.
Failed before the fix (the original `stream-alarms --session-id <id> …` line
emitted `Error: No such option: --session-id`), passes after.

### Client.Python-023

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Security |
| Location | `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:901-906` |
| Status | Resolved |

**Description:** Client.Python-013 (severity Medium, Security) was marked
**Resolved** on 2026-05-20 with the explicit claim that the silent
`localhost:` / `127.0.0.1:` auto-plaintext branch had been removed from
`_use_plaintext`. The re-review at `d692232` re-asserted this in its
checklist ("`_use_plaintext` now requires explicit `--plaintext` opt-in
(Client.Python-013 resolution verified)").

The branch is still present in the reviewed source at HEAD `42b0037`:

```python
def _use_plaintext(kwargs: dict[str, Any]) -> bool:
    if kwargs.get("use_tls"):
        return False
    if kwargs.get("plaintext"):
        return True
    return kwargs["endpoint"].startswith("localhost:") or kwargs["endpoint"].startswith("127.0.0.1:")
```

The same code is present in `git show d692232:clients/python/src/zb_mom_ww_mxgateway_cli/commands.py`, so the regression entered at or
before the rename commit `397d3c5` (which created
`src/zb_mom_ww_mxgateway_cli/commands.py` from scratch with the
pre-Client.Python-013 body) and was not noticed in the prior re-review.

The original security argument is unchanged: a user who runs the gateway
behind TLS on loopback for production-shaped local testing and passes
`--api-key mxgw_<secret>` against `localhost:5001` silently gets a plaintext
gRPC channel, with the bearer token attached to it. The other clients
(.NET https-prefix detection, Go / Java explicit `--plaintext`, Rust
opt-in) do not auto-downgrade. The Client.Python-013 resolution also added
six regression tests in `tests/test_cli.py` that asserted the explicit-flag
contract; those tests do not exist in the current `tests/test_cli.py` —
either they were lost in the rename or never carried over.

**Recommendation:** Re-apply the Client.Python-013 fix on the current
source: drop the `endpoint.startswith("localhost:") or endpoint.startswith("127.0.0.1:")`
branch, make `--plaintext` and `--tls` mutually exclusive, default to TLS,
and add an assertion-time test that copy-pastes the Client.Python-013
regression-test fixture into `tests/test_cli.py`. Because Client.Python-013
is marked Resolved with a 2026-05-20 commit reference, do **not** silently
re-resolve this finding — keep it Open with a fresh ID so the regression
audit trail is preserved.

**Resolution:** 2026-05-24 — Re-applied the Client.Python-013 fix on the
renamed CLI module. Dropped the `endpoint.startswith("localhost:") or
endpoint.startswith("127.0.0.1:")` auto-plaintext branch from
`_use_plaintext` in `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py`.
TLS is now the default and `--plaintext` is the only way to opt in to
plaintext; `--tls` is accepted as a redundant affirmation and the two
flags combined raise `click.UsageError`. Regression tests live in
`tests/test_review_findings_022_to_026.py`:
`test_use_plaintext_does_not_auto_downgrade_for_localhost_endpoint` and
`test_use_plaintext_does_not_auto_downgrade_for_loopback_ipv4_endpoint`
exercise the bare-endpoint path,
`test_use_plaintext_requires_explicit_plaintext_flag` and
`test_use_plaintext_tls_flag_explicitly_disables_plaintext` pin the explicit
opt-in / opt-out contract,
`test_use_plaintext_rejects_plaintext_and_tls_combined` asserts mutual
exclusivity, and
`test_cli_localhost_endpoint_with_no_flags_uses_tls_channel` is an
end-to-end CliRunner test that intercepts `GatewayClient.connect` and
asserts the resolved `ClientOptions.plaintext` is `False` for a
`localhost:5000` endpoint without `--plaintext`. All five tests failed
against the pre-fix source and pass against the fix. **Behaviour change for
callers:** scripts that previously relied on
`mxgw-py … --endpoint localhost:5000 …` selecting plaintext silently must
now add an explicit `--plaintext` flag (or set up TLS on the gateway).

### Client.Python-024

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Code organization & conventions |
| Location | `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:13,48-119` |
| Status | Resolved |

**Description:** The new `batch` subcommand (commit `71d2c39`) implements
the cross-language batch protocol by importing `click.testing.CliRunner`
into production code and calling `runner.invoke(main, args, catch_exceptions=True)`
in a `for raw_line in sys.stdin:` loop. `CliRunner` is documented as a
**testing** helper:

* It replaces `sys.stdin` / `sys.stdout` / `sys.stderr` with `io.StringIO`
  during each `invoke()`, swallowing any side-channel output the inner
  command writes directly to the real streams (the existing CLI bodies do
  not, but any future helper that calls `print()` mid-command will be
  silently captured into `result.output` rather than reaching the
  harness real-time).
* It captures the inner command into an `Exception` rather than letting
  Click's normal exit code propagate, so `result.exit_code` is the
  pseudo-exit of a `SystemExit` translation, not the real process exit.
* Click does not guarantee `CliRunner` is stable across versions —
  click 9 has already deprecated `runner.invoke(..., mix_stderr=...)`,
  and a future Click release could change the return-tuple shape.
* It is recursively re-entrant: `runner.invoke(main, ["batch"], ...)`
  inside batch silently spawns a nested batch reading from the same
  StringIO-replaced stdin (empty), so a stdin line of `batch` exits
  cleanly with no error — almost certainly not the intended semantics.

The other client CLIs in the matrix (.NET, Go, Rust, Java) implement
`batch` by dispatching to their command parser directly, not by
re-invoking the test runner.

**Recommendation:** Replace `CliRunner` with a direct call into the Click
parser, e.g. `main.main(args, standalone_mode=False)` wrapped in a
`try/except click.ClickException` to convert Click exit conditions into
the `{"error": ..., "type": ...}` payload. Capture stdout via a per-line
context manager (e.g. `contextlib.redirect_stdout(io.StringIO())`) so the
batch loop can interleave inner-command output with the
`__MXGW_BATCH_EOR__` sentinel without depending on the testing API. Add
a regression test that drives `batch` with `batch\n` on stdin and asserts
recursive invocation is either rejected or correctly bounded.

**Resolution:** 2026-05-24 — Removed the `from click.testing import CliRunner`
import and the `CliRunner()` instantiation from
`clients/python/src/zb_mom_ww_mxgateway_cli/commands.py`. The `batch`
command body now dispatches each stdin line through a new
`_dispatch_batch_line` helper that calls `main.main(args=…,
standalone_mode=False, prog_name="mxgw-py")` directly and captures the
subcommand's stdout via `contextlib.redirect_stdout(io.StringIO())`. Click
exit conditions (`click.exceptions.Exit`, `click.ClickException`,
`SystemExit`) are caught and rendered as
`{"error": …, "type": …}` JSON; arbitrary exceptions are caught with a
broad `except Exception` so the batch loop never dies. A nested `batch`
line is rejected outright with a `RecursiveBatchError` JSON record before
the dispatcher runs, eliminating the silent-recursive-spawn footgun the
original `CliRunner.invoke(main, ["batch"], …)` path enabled. Regression
tests:
`tests/test_review_findings_022_to_026.py::test_batch_command_does_not_use_clirunner_in_production`
asserts the production module no longer imports `from click.testing` or
calls `CliRunner(`; and
`test_batch_recursive_batch_line_is_bounded` drives a `batch\nversion --json\n`
stdin payload and asserts the recursive `batch` line emits an error JSON
record rather than silently exiting. The pre-existing batch tests
(`test_batch_runs_version_command_and_writes_eor`,
`test_batch_terminates_on_empty_line`,
`test_batch_continues_after_error_line`) still pass against the new
implementation, confirming the wire-level contract (one EOR per line,
clean JSON error blocks) is preserved.

### Client.Python-025

| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `clients/python/tests/test_cli.py`, `clients/python/src/zb_mom_ww_mxgateway/{client.py,session.py}`, `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py` |
| Status | Resolved |

**Description:** Commits `6add4b4` and `828e3e6` added five new SDK
methods (`Session.read_bulk`, `Session.write_bulk`, `Session.write2_bulk`,
`Session.write_secured_bulk`, `Session.write_secured2_bulk`),
`GatewayClient.stream_alarms`, the helper
`_canceling_alarm_feed_iterator`, and eight new CLI subcommands
(`read-bulk`, `write-bulk`, `write2-bulk`, `write-secured-bulk`,
`write-secured2-bulk`, `bench-read-bulk`, `stream-alarms`,
`acknowledge-alarm`). The only test coverage added in `tests/test_cli.py`
is:

* `test_stream_alarms_is_registered` — `--help` smoke only.
* `test_acknowledge_alarm_requires_reference` — verifies the missing-flag
  Click error contains `--reference`; no happy-path test.

There is no test that:

1. Asserts `Session.read_bulk` / `write_bulk` / `write2_bulk` /
   `write_secured_bulk` / `write_secured2_bulk` builds the expected
   `MxCommand` shape (kind, sub-message, server_handle, entries) — the
   prior Client.Python-009 coverage pattern (`test_add_item2_sends_*`,
   `test_write2_sends_value_and_timestamp_value`) is not extended to the
   bulk family even though they ship the same wire-shape risk.
2. Exercises `_canceling_alarm_feed_iterator` cancel-on-task-cancellation
   (the Client.Python-007 helper test pattern is not extended).
3. Drives `stream-alarms` / `acknowledge-alarm` / `read-bulk` /
   `write-bulk` / `write-secured-bulk` happy paths through `CliRunner`
   with a fake stub — the existing
   `test_cli_register_happy_path_emits_server_handle` pattern is not
   extended.
4. Asserts `bench-read-bulk` emits the cross-language schema for the new
   `read-bulk`-shaped fields (the Client.Python-015 pattern existed for
   the previous bench command but no equivalent exists for this one).

A silent drift in any of the four bulk-write request shapes — or a
schema drift on `bench-read-bulk` — would not be caught.

**Recommendation:** Extend the Client.Python-009 / Client.Python-015 /
Client.Python-016 patterns: add request-shape tests for the four new
bulk methods, a CLI happy-path test for `read-bulk` / `write-bulk` /
`stream-alarms` / `acknowledge-alarm` against fake stubs, and a
cross-language schema test for `bench-read-bulk` mirroring
`test_bench_read_bulk_emits_cross_language_schema` (with `--read-bulk`
applied to the renamed bench). At minimum, add a request-shape test for
`write_secured_bulk` since the secured family is the highest-risk
parity surface.

**Resolution:** 2026-05-24 — Added behavioural test coverage for the five
new bulk SDK methods, `stream_alarms`, and the new CLI subcommand bodies
in `tests/test_review_findings_022_to_026.py`. Request-shape tests
(`test_session_read_bulk_sends_expected_request_shape`,
`test_session_write_bulk_sends_expected_request_shape`,
`test_session_write2_bulk_sends_expected_request_shape`,
`test_session_write_secured_bulk_sends_expected_request_shape`,
`test_session_write_secured2_bulk_sends_expected_request_shape`) drive
each `Session.*_bulk` method against a fake `Invoke` stub and assert
the captured `MxCommand`'s `kind`, sub-message, `server_handle`, and
per-entry fields (including `current_user_id` / `verifier_user_id`
on the secured family — the highest-risk parity surface the finding
calls out). `test_stream_alarms_yields_feed_messages_and_cancels_on_close`
covers the `GatewayClient.stream_alarms` happy path including the
`_canceling_alarm_feed_iterator` cancel-on-close contract and the
authorization metadata header. CLI happy-path tests
(`test_cli_read_bulk_happy_path`, `test_cli_write_bulk_happy_path`,
`test_cli_stream_alarms_happy_path`, `test_cli_acknowledge_alarm_happy_path`)
each drive their subcommand through `CliRunner` against a fake stub
injected via a monkeypatched `GatewayClient.connect` and assert the
emitted JSON shape and that the captured RPC request carries the
expected fields. The four CLI happy-path tests passed even before any
production fix (the implementations were correct; the finding is a
coverage gap), but they now exist as regression guards against future
drift. No source change — pure coverage finding.

### Client.Python-026

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:674-738` |
| Status | Resolved |

**Description:** Two minor quality issues in the new `_bench_read_bulk`
body (commit `6add4b4`):

1. `import time` is done inside the function body (line 676) rather than
   at module top. `PythonStyleGuide.md` does not state this explicitly,
   but every other helper in `commands.py` imports its dependencies at
   module top, and `time` is already imported (transitively) elsewhere
   in the package. The function-local import is a vestige of incremental
   development and should be hoisted.
2. The `finally` cleanup block uses two consecutive bare
   `except Exception: pass` blocks to swallow `unsubscribe_bulk` and
   `session.close()` failures (lines 733-734 and 737-738). This silently
   discards diagnostic information about cleanup failures — e.g. a
   transient gateway crash mid-benchmark or a protocol error during
   unsubscribe — and matches an anti-pattern the rest of the module
   avoids (the `_bench_read_bulk` analogues in the other clients log the
   cleanup failure before swallowing it).

Both are Low severity. The bench command is best-effort by design (the
benchmark output is what matters; cleanup failures are not user-facing),
but a single log line on cleanup failure would make a future regression
visible at the next benchmark run rather than silently corrupting the
worker's subscription bookkeeping until a session-level GC sweep.

**Recommendation:** Move `import time` to the module-level import block.
Replace each `except Exception: pass` with `except Exception as exc:
logger.warning("bench-read-bulk cleanup: %s", exc)` against a
module-level `logger = logging.getLogger(__name__)`. No behavioural
change in the happy path; failure path becomes diagnosable. No new test
required for the import hoist; the logger change is exercised by the
existing bench smoke test once `caplog` is added to the test signature.

**Resolution:** 2026-05-24 — Hoisted `import time` to the module-level
import block in `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py`
alongside the existing standard-library imports; the function-local
`import time` line at the top of `_bench_read_bulk` is gone. Added a
module-level `logger = logging.getLogger(__name__)` and rewrote the two
`finally` cleanup blocks to bind the swallowed exception and log it at
`WARNING` level — `unsubscribe_bulk` failures now emit
`"bench-read-bulk: unsubscribe_bulk cleanup failed: %s"` and the
`session.close()` failure path emits the equivalent — so a future
regression in the cleanup path is diagnosable at the next benchmark run
rather than silently corrupting subscription bookkeeping. Regression
tests in `tests/test_review_findings_022_to_026.py`:
`test_commands_module_imports_time_at_module_scope` uses
`inspect.getsource(_bench_read_bulk)` to assert no function-local
`import time` line, and asserts the module exposes `time` at module
scope; `test_commands_module_bench_read_bulk_does_not_use_bare_except_pass`
greps the function source for the `except Exception:\n    pass` pattern
and rejects it. Both tests failed against the pre-fix source and pass
against the fix.

### Client.Python-027

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Security |
| Location | `clients/python/src/zb_mom_ww_mxgateway/client.py:36-54`, `clients/python/src/zb_mom_ww_mxgateway/galaxy.py:47-66`, `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:165-172,918-930` |
| Status | Resolved |

**Description:** This commit adds `ClientOptions.require_certificate_validation`
(default `False`) so a caller can force system-trust certificate verification
instead of the new lenient trust-on-first-use (TOFU) default. The flag is
honoured inside `create_channel`, but it is not surfaced through either of the
two documented ways a normal caller dials the gateway:

1. `GatewayClient.connect(...)` and `GalaxyRepositoryClient.connect(...)` accept
   the convenience kwargs `endpoint` / `api_key` / `plaintext` / `ca_file` /
   `server_name_override` and build the `ClientOptions` internally, but do **not**
   accept or forward `require_certificate_validation`. The README's high-level
   examples (e.g. the lazy-browse walker) use exactly this kwarg form
   (`GalaxyRepositoryClient.connect(endpoint=..., api_key=..., plaintext=True)`),
   so the kwarg path is the primary documented entry point.
2. The CLI exposes `--plaintext`, `--tls`, and `--ca-file` but no
   `--require-certificate-validation` flag, and `_connect` constructs
   `ClientOptions(...)` without setting the field. A CLI user connecting to a
   TLS gateway is therefore locked into TOFU.

The net effect is that the *only* way to opt into real certificate validation is
to construct a `ClientOptions` instance directly and pass it as the positional
`options=` argument — a path neither the README nor the CLI documents. A
security-sensitive deployment that wants the strict (verify-against-system-trust)
posture cannot select it through the documented surface, so it silently stays on
TOFU. TOFU itself is design-sanctioned (`docs/GatewayConfiguration.md` line 470
explicitly says "Python uses trust-on-first-use"), so this is an opt-in-to-strict
reachability gap rather than an insecure default — hence Medium with a workaround.

**Recommendation:** Add a `require_certificate_validation: bool = False` kwarg to
both `GatewayClient.connect` and `GalaxyRepositoryClient.connect` and forward it
into the constructed `ClientOptions`. Add a `--require-certificate-validation`
(or `--verify-tls`) flag to the shared CLI option set and wire it through
`_connect`. Add a test asserting the flag flows through to
`ClientOptions.require_certificate_validation` and a README note documenting how
to select the strict posture.

**Resolution:** 2026-06-15 — Confirmed: `connect` built `ClientOptions` from a
fixed kwarg set that omitted `require_certificate_validation`, and the CLI had no
flag, so the strict posture was only reachable via a hand-built `options=`. Added
a `require_certificate_validation: bool = False` kwarg to both
`GatewayClient.connect` and `GalaxyRepositoryClient.connect` (forwarded into the
constructed `ClientOptions`), a `--require-certificate-validation` flag to the
shared `gateway_options` CLI option set, and wired it through `_connect`. README
TLS section now documents the strict posture is reachable via the connect kwarg,
the options struct, and the CLI flag. Tests:
`tests/test_client_session.py::test_gateway_connect_forwards_require_certificate_validation`,
`::test_galaxy_connect_forwards_require_certificate_validation`,
`tests/test_cli.py::test_require_certificate_validation_flag_flows_through_connect`,
`::test_require_certificate_validation_defaults_off` — all failed before the fix
and pass after.

### Client.Python-028

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | `clients/python/src/zb_mom_ww_mxgateway/options.py:120-130`, `clients/python/src/zb_mom_ww_mxgateway/client.py:59`, `clients/python/src/zb_mom_ww_mxgateway/galaxy.py:71` |
| Status | Resolved |

**Description:** The TOFU branch of `create_channel` calls
`ssl.get_server_certificate((host, port))` to pre-fetch the server certificate.
`create_channel` is a synchronous function, but it is invoked exclusively from
inside the `async def connect` classmethods of `GatewayClient` and
`GalaxyRepositoryClient` (`client.py:59`, `galaxy.py:71`). `ssl.get_server_certificate`
opens a real blocking TCP+TLS socket on the calling thread, so:

1. It **blocks the asyncio event loop** for the full duration of the connect/handshake.
   This is at odds with the rest of the client, which is fully `async`.
2. It passes **no `timeout`** to `ssl.get_server_certificate`. The `test_tofu_connect_failure_raises_transport_error`
   test only proves the *connection-refused* case (a closed port returns fast).
   A black-holed / firewall-drop host (packets silently dropped) makes the
   underlying `socket.create_connection` hang on the OS default connect timeout,
   which can be minutes, with the event loop frozen the whole time. A caller that
   wrapped `connect` in `asyncio.wait_for(...)` cannot cancel it because the block
   is in synchronous C, not at an `await` point.

The other TLS branches (`ca_file`, `require_certificate_validation`) build the
channel lazily and return immediately, so only the lenient default — the most
common path — has this hazard.

**Recommendation:** Pass an explicit `timeout=` to `ssl.get_server_certificate`
(it accepts one), bounded by `options.call_timeout` or a short fixed value, so a
black-holed host fails fast as a `MxGatewayTransportError` instead of hanging.
Better, run the synchronous probe off the event loop — make the TOFU pre-fetch
path awaitable (e.g. wrap it in `asyncio.get_running_loop().run_in_executor(...)`
from an `async` channel factory, or document that `connect` must not be called
from a running loop). Add a regression test that asserts the probe honours a
timeout.

**Resolution:** 2026-06-15 — Confirmed: the TOFU branch called
`ssl.get_server_certificate((host, port))` with no timeout from the synchronous
`create_channel`, which both `connect` classmethods invoked directly on the event
loop. Fix is two-part: (1) `create_channel` now passes
`timeout=options.call_timeout` (falling back to a fixed
`_TOFU_PROBE_TIMEOUT_SECONDS = 10.0` when no call_timeout is set) to
`ssl.get_server_certificate`, and the existing `except OSError` wraps a
timeout/connect failure into `MxGatewayTransportError` (TimeoutError/socket.timeout
are OSError subclasses); (2) both `GatewayClient.connect` and
`GalaxyRepositoryClient.connect` now run the blocking factory off the loop via
`await asyncio.to_thread(create_channel, resolved)`, so the event loop is never
frozen and a caller's `asyncio.wait_for` can cancel the connect. Tests:
`tests/test_auth_options.py::test_tofu_probe_passes_a_bounded_timeout`,
`::test_tofu_probe_timeout_raises_transport_error` (parametrized over
socket.timeout / TimeoutError / OSError), and
`tests/test_client_session.py::test_gateway_connect_runs_create_channel_off_the_event_loop`,
`::test_galaxy_connect_runs_create_channel_off_the_event_loop`. The timeout and
off-loop tests failed before the fix and pass after.

### Client.Python-029

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `clients/python/src/zb_mom_ww_mxgateway/options.py:78-90` |
| Status | Resolved |

**Description:** `_split_authority` parses a non-bracketed target with
`host, _, port = target.rpartition(":")` and returns
`(host or "localhost", int(port) if port else 443)`. For a port-less endpoint
such as `"mygateway"`, `rpartition(":")` returns `("", "", "mygateway")`, so
`host` becomes `""` (→ `"localhost"`) and `port` becomes `"mygateway"`, and
`int("mygateway")` raises an uncaught `ValueError: invalid literal for int()`.
Because `_split_authority` is called *before* the `try/except OSError` guard in
`create_channel`, the failure escapes as a raw `ValueError` rather than the
intended `MxGatewayTransportError`, and the message does not name the endpoint.
Verified at runtime:
`_split_authority("mygateway")` → `ValueError: invalid literal for int() with base 10: 'mygateway'`.
gRPC targets normally carry an explicit port (`host:port`), so impact is narrow,
but a typo or a bare-hostname endpoint produces a confusing crash on the TOFU
default path. The bracketed-IPv6 and `host:port` cases are covered by tests; the
port-less case is not.

**Recommendation:** Treat a non-numeric / missing port as the default (443) and
keep the whole string as the host, e.g. detect a trailing `:<digits>` explicitly
rather than assuming the `rpartition` tail is numeric, or wrap the `int(port)`
conversion so a non-numeric tail falls back to host-only with the default port.
Add a `_split_authority("mygateway")` case to `tests/test_tls.py`.

**Resolution:** 2026-06-15 — Confirmed: `_split_authority("mygateway")` raised
`ValueError: invalid literal for int() with base 10: 'mygateway'` because
`rpartition(":")` put the whole string in the port slot. Rewrote the
non-bracketed branch to inspect the `rpartition` separator and the tail: no colon
→ whole target is the host with default port 443; a colon with a non-digit/empty
tail → left side is the host with default port 443; a digit tail → parse the
port. The bare-hostname case now returns `("mygateway", 443)` instead of raising,
and the existing `":5120"` / `"localhost:5120"` / IPv6 cases are unchanged. Test:
`tests/test_tls.py::test_split_authority_defaults_port_for_portless_endpoint`
(covers `"mygateway"`, `"https://mygateway"`, and `"mygateway:"`) — failed before
the fix and passes after.

### Client.Python-030

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `clients/python/pyproject.toml:17` |
| Status | Resolved |

**Description:** This commit re-adds a `license` key to `pyproject.toml` as the
table form `license = { text = "Proprietary" }`. Under PEP 639 (active in the
installed setuptools 82.0.1), the `[project.license]` **table** forms (`text` and
`file`) are deprecated in favour of the SPDX string expression, and a future
setuptools major may reject them — the same class of regression that
Client.Python-018 (the earlier `license = "Proprietary"` string, rejected because
`Proprietary` is not a valid SPDX identifier) recorded for this exact field. The
build currently succeeds (verified: `python -m pip wheel .` produces
`zb_mom_ww_mxaccess_gateway_client-0.1.0-py3-none-any.whl` and the metadata
carries `License: Proprietary` plus the `License :: Other/Proprietary License`
classifier), so this is a forward-looking maintainability flag, not a present
breakage. Note that pairing a `license` table with a `License ::` trove
classifier is also flagged by PyPI/twine as redundant under the new metadata
rules.

**Recommendation:** Prefer the PEP 639 SPDX-string form with a `LicenseRef-*`
custom identifier for an unlisted licence (`license = "LicenseRef-Proprietary"`)
— this is the future-proof equivalent of the intent and avoids the deprecated
table form — or drop the `license` key entirely and rely on the existing
`License :: Other/Proprietary License` classifier (the Client.Python-018
resolution chose this). The `tests/test_packaging.py::test_pip_wheel_build_succeeds`
guard (added under Client.Python-020) will catch the day a setuptools upgrade
turns the deprecation into a hard error.

**Resolution:** 2026-06-15 — Switched the deprecated `license = { text =
"Proprietary" }` table form to the PEP 639 SPDX-string form
`license = "LicenseRef-Proprietary"` (the future-proof custom identifier for an
unlisted/proprietary licence). Also removed the now-redundant
`License :: Other/Proprietary License` trove classifier, which setuptools >= 77
flags as conflicting when a `License-Expression` is present. The built wheel
metadata now carries `License-Expression: LicenseRef-Proprietary` and no
`Classifier: License ::` line. Verified by `python -m pip wheel . --no-deps`,
which builds cleanly; the existing
`tests/test_packaging.py::test_pip_wheel_build_succeeds` guard exercises the same
build and passes.

### Client.Python-031

| Field | Value |
|---|---|
| Severity | Low |
| Category | Testing coverage |
| Location | `clients/python/tests/test_tls.py:34`, `clients/python/pyproject.toml:53-56` |
| Status | Resolved |

**Description:** `tests/test_tls.py` applies a module-level
`pytestmark = pytest.mark.tls`, but the `tls` marker is not registered in
`[tool.pytest.ini_options]` (which declares only `addopts`, `pythonpath`, and
`testpaths`). Every run emits a `PytestUnknownMarkWarning: Unknown
pytest.mark.tls - is this a typo?`. The warning is benign today, but (a) it is
exactly the kind of typo the warning exists to catch, so a future genuine
mistyped marker would be lost in the noise, and (b) if the suite ever adopts
`filterwarnings = ["error"]` (a common hardening step), the unregistered marker
would turn into a hard collection failure.

**Recommendation:** Register the marker, e.g.
`markers = ["tls: loopback TLS tests, opt-in via MXGATEWAY_RUN_TLS_TESTS=1"]`
under `[tool.pytest.ini_options]` in `clients/python/pyproject.toml`.

**Resolution:** 2026-06-15 — Registered the `tls` marker by adding
`markers = ["tls: loopback TLS tests, opt-in via MXGATEWAY_RUN_TLS_TESTS=1"]`
under `[tool.pytest.ini_options]` in `clients/python/pyproject.toml`.
`python -m pytest` now reports no `PytestUnknownMarkWarning` (full run: 91
passed, 1 skipped, 0 warnings; previously 1 warning). The `tls`-marked
`tests/test_tls.py` module is the guard — its run is now warning-free.

### Client.Python-032

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:1048,1065-1066` |
| Status | Resolved |

**Description:** `_smoke` reintroduces the dead `closed = False` / `if not closed:` guard that Client.Python-004's resolution claimed to have removed via `async with session:`. `closed` is never reassigned, so the guard is always true. Behavior is correct (session always closed) but the dead variable misleads readers into expecting an early-close path. (Claimed regression — verify root cause.)

**Recommendation:** Use `async with session:` or drop the `closed` variable and close unconditionally.

**Resolution:** 2026-06-16 — Confirmed regression: the dead `closed = False` / `if not closed:` guard had returned. Replaced the `try/finally` with `async with session:` (Session implements the async context-manager protocol). Test: `test_smoke_does_not_carry_dead_closed_guard` in `tests/test_review_findings_032_to_036.py`.

### Client.Python-033

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:772,1490-1494` |
| Status | Resolved |

**Description:** `_parse_string_list` always emits `param_hint="--items"`, but it is also called from `_build_write_bulk_entries` with `kwargs["values"]`. An empty `--values ""` on the write-bulk commands yields `Error: Invalid value for '--items': ...`, pointing at a flag that doesn't exist on those commands.

**Recommendation:** Add an optional `param_hint` parameter (default `--items`) and pass `--values` from the write-bulk caller.

**Resolution:** 2026-06-16 — Added `param_hint="--items"` default param to `_parse_string_list`; `_build_write_bulk_entries` now passes `param_hint="--values"`. Tests: `test_parse_string_list_default_param_hint_is_items`, `test_parse_string_list_accepts_caller_supplied_param_hint` in `tests/test_review_findings_032_to_036.py`.

### Client.Python-034

| Field | Value |
|---|---|
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:1497-1501` |
| Status | Resolved |

**Description:** `_parse_int_list` does `int(item)` with no error handling. A non-numeric token (e.g. `--item-handles "10,abc"`) raises a raw `ValueError`, surfacing as an unformatted traceback interactively (other input errors raise `click.BadParameter`).

**Recommendation:** Wrap the conversion and re-raise as `click.BadParameter(..., param_hint="--item-handles")`.

**Resolution:** 2026-06-16 — Wrapped the `int()` comprehension in `try/except ValueError` and re-raise as `click.BadParameter(..., param_hint="--item-handles")`. Tests: `test_parse_int_list_non_numeric_raises_bad_parameter`, `test_parse_int_list_happy_path` in `tests/test_review_findings_032_to_036.py`.

### Client.Python-035

| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | `clients/python/src/zb_mom_ww_mxgateway/__init__.py`, `.../options.py:63-77`, `.../galaxy.py:293` |
| Status | Resolved |

**Description:** Two new public types — `BrowseChildrenOptions` (options.py) and `LazyBrowseNode` (galaxy.py) — are absent from `__init__.py`/`__all__`, so callers can't `from zb_mom_ww_mxgateway import BrowseChildrenOptions`, breaking the package-root import contract that `ClientOptions`/`GatewayClient`/etc. follow.

**Recommendation:** Re-export both from `__init__.py` and add them to `__all__`.

**Resolution:** 2026-06-16 — Re-exported `BrowseChildrenOptions` (from `.options`) and `LazyBrowseNode` (from `.galaxy`) in `__init__.py` and added both to `__all__`. Tests: `test_browse_children_options_is_exported_from_package_root`, `test_lazy_browse_node_is_exported_from_package_root` in `tests/test_review_findings_032_to_036.py`.

### Client.Python-036

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Documentation & comments |
| Location | `clients/python/README.md:143-158` |
| Status | Resolved |

**Description:** The README "Browsing lazily" section's code example calls `galaxy.browse_children(...)`, a method that does not exist — the actual public low-level method is `browse_children_raw`. The example raises `AttributeError` at runtime. The README-parse test only covers shell CLI invocations, not Python code fragments, so it doesn't catch this.

**Recommendation:** Update the example/prose to `browse_children_raw(...)` (and promote the high-level `browse()`/`LazyBrowseNode` path), or add a `browse_children` alias. Add a `hasattr` test to catch future renames.

**Resolution:** 2026-06-16 — Updated the README "Browsing lazily" prose and example to `browse_children_raw(...)` and added a pointer to the higher-level `browse()`/`LazyBrowseNode` walker. Tests: `test_galaxy_client_exposes_browse_children_raw` (hasattr guard) and `test_readme_browse_example_uses_existing_method` (parses every `galaxy.<method>()` call in README against the client class) in `tests/test_review_findings_032_to_036.py`.
