fix(code-review): resolve Batch 2 open findings (AbCip, AbLegacy, Galaxy, FOCAS)

- Driver.AbCip.Contracts-001: parse 'writable' from TagConfig JSON (default true) instead of hardcoding
- Driver.AbCip.Contracts-002/-003: Dt type comment; drop dead [Display]/[Range] annotations
- Driver.AbCip.Contracts-004: dedicated AbCipEquipmentTagParser test class (+15)
- Driver.AbCip-017: document Tick severity Low-fallback on Bad severity read
- Driver.AbLegacy.Contracts-002/-003/-004: isArray-scalar remarks (+tests), MaxTagBytes/ForFamily docs
- Driver.Galaxy.Browser-003 + Driver.Galaxy.Contracts-003: extract ResolveApiKey -> GalaxySecretRef (dedup)
- Driver.Galaxy-019: cache buffered-interval only on Ok + ILogger warnings + ClassifyIntervalReply (+tests)
- Driver.FOCAS.Contracts-002: thread WriteIdempotent through DiscoverAsync (+test)
This commit is contained in:
Joseph Doherty
2026-06-20 22:43:36 -04:00
parent 3cc6a5f30d
commit ab57e53b92
26 changed files with 577 additions and 220 deletions
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `a19b0f86` |
| Status | Reviewed |
| Open findings | 4 |
| Open findings | 0 |
## Checklist coverage
@@ -44,7 +44,7 @@ a category produced nothing rather than leaving it blank.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs:42` |
| Status | Open |
| Status | Resolved |
**Description:** `AbCipEquipmentTagParser.TryParse` hard-codes `Writable: true` on every
equipment-tag definition it produces, regardless of any `writable` field in the TagConfig JSON.
@@ -70,7 +70,14 @@ record's `Writable` parameter. (2) Either return `false` from `TryParse` when `d
to `AbCipDataType.Structure` (equipment-tag flow cannot declare members), or add an explicit
comment documenting the black-box dotted-path behaviour so the next reader understands the intent.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20 — (1) `TryParse` now reads the optional `"writable"` boolean
field from the TagConfig JSON and threads it into `AbCipTagDefinition.Writable`, defaulting to
`true` when the field is absent. The `<remarks>` on `TryParse` was updated to document this
behaviour. (2) For the Structure concern, zero-behaviour-change option taken: an inline comment
was added at the `dataType` parse site in `TryParse` documenting that a `Structure` dataType on an
equipment tag is treated as a black-box dotted-path read (libplctag resolves the full path; the
equipment-tag flow does not enumerate UDT members). New tests in `AbCipEquipmentTagParserTests`
cover `writable:false`, `writable` absent, and the `Structure` path. Suite green (322 tests).
---
@@ -81,7 +88,7 @@ comment documenting the black-box dotted-path behaviour so the next reader under
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDataType.cs:28` |
| Status | Open |
| Status | Resolved |
**Description:** The inline comment on `AbCipDataType.Dt` reads:
@@ -114,7 +121,9 @@ Dt, // Logix DATE (0xCD — 4-byte unsigned days since 1984-01-01) or DATE_A
No behaviour change; documentation only.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20 — replaced the inaccurate single-line comment on `Dt` with a
3-line comment describing both mapped CIP types (`0xCD` DATE / `0xCF` DT), the 4-byte read stride,
and the truncation note for DATE_AND_TIME. Build green (0 errors, 0 warnings).
---
@@ -125,7 +134,7 @@ No behaviour change; documentation only.
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs:84-85` |
| Status | Open |
| Status | Resolved |
**Description:** `AbCipDriverOptions.ProbeTimeoutSeconds` carries `[Display]` and `[Range(1, 60)]`
attributes from `System.ComponentModel.DataAnnotations`. No other driver contracts project
@@ -144,7 +153,11 @@ If the intent is to document the valid range, an `<remarks>` tag (e.g. "Valid ra
seconds; the AdminUI clamps to 60s server-side.") achieves the same goal without the attribute
dependency.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20 — removed `[Display]` and `[Range(1, 60)]` from
`ProbeTimeoutSeconds` and removed the `using System.ComponentModel.DataAnnotations;` directive
(confirmed it was used only by those two attributes). Added a `<remarks>` element reading
"Valid range: 160 seconds; the AdminUI clamps to 60s server-side." to preserve the intent
in documentation. Build green (0 errors, 0 warnings).
---
@@ -155,7 +168,7 @@ dependency.
| Severity | Low |
| Category | Testing coverage |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs` (entire file) |
| Status | Open |
| Status | Resolved |
**Description:** The contracts module has no dedicated test project, and `AbCipEquipmentTagParser.TryParse`
is the module's only non-trivial logic. Its `ReadArrayShape` helper has four distinct outcome
@@ -175,7 +188,14 @@ is needed. Cover: valid scalar round-trip, 1-element array, N-element array, eac
array-shape combination, non-JSON and non-object input, missing/blank `tagPath`, the Structure
DataType path, and the `Writable` default.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20 — added `AbCipEquipmentTagParserTests.cs` to
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` (15 tests). Covers: valid scalar
round-trip; 1-element array (`isArray:true, arrayLength:1`); N-element array; `isArray:true +
arrayLength:0` → scalar; `isArray:true + arrayLength absent` → scalar; non-JSON input → false;
non-object JSON array → false; non-object JSON string → false; missing `tagPath` → false; blank
`tagPath` → false; `tagPath` as number → false; `writable:false` honoured; `writable` absent
defaults to `true`; `writable:true` explicit; `dataType:"Structure"` accepted with `Members:null`
(documents current black-box behaviour). Suite green (322 tests).
---
+8 -2
View File
@@ -7,7 +7,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -337,7 +337,7 @@ flag and the legacy `ElementCount > 1` paths opt out). Full suite green (303 tes
| Severity | Low |
| Category | Documentation & comments |
| Location | `AbCipAlarmProjection.cs:173-185` (`Tick`) |
| Status | Open |
| Status | Resolved |
**Description:** `AbCipAlarmProjection.Tick` gates each node on the `InFaulted` snapshot's
`StatusCode` (`if (inFaultedDv.StatusCode != Good) continue;`) but reads the `Severity`
@@ -357,6 +357,12 @@ severity read is Bad, or add an XML/inline comment on `Tick` stating that severi
decision (what severity to surface when it is genuinely unknown) rather than a mechanical fix,
and the impact is negligible given the single-batch read shape.
**Resolution:** Resolved 2026-06-20 — added an inline comment on the `severity` read line in
`Tick` documenting that `severityDv.StatusCode` is not checked, that a Bad Severity read yields
`ToInt(null)=0``MapSeverity` buckets as Low, and that this is acceptable because InFaulted
and Severity are members of the same ALMD UDT read in one batch (a Good InFaulted almost always
implies a Good Severity). No behaviour change.
### Driver.AbCip-018
| Field | Value |
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `a19b0f86` |
| Status | Reviewed |
| Open findings | 3 |
| Open findings | 0 |
## Checklist coverage
@@ -69,7 +69,7 @@ verified by build (no test project in this module).
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs:15` |
| Status | Open |
| Status | Resolved |
**Description:** `AbLegacyEquipmentTagParser.TryParse` has an undocumented edge case: when
`isArray` is the JSON literal `true` but `arrayLength` is absent, zero, or negative,
@@ -85,7 +85,13 @@ without a valid positive `arrayLength` silently produces a scalar (null `ArrayLe
Optionally add a unit test to `AbLegacyEquipmentTagTests` covering
`isArray:true, arrayLength:0/absent -> null` for regression protection.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20. Added a `<remarks>` block to `TryParse` in
`AbLegacyEquipmentTagParser.cs` documenting that `isArray:true` without a valid positive
`arrayLength` silently produces a scalar (`ArrayLength` is `null`), referencing the
in-source review C-2 rationale. Added three regression unit tests to
`AbLegacyEquipmentTagTests`: `IsArray_true_with_arrayLength_zero_produces_scalar`,
`IsArray_true_with_no_arrayLength_produces_scalar`, and
`IsArray_true_with_negative_arrayLength_produces_scalar`. Suite: 199 passed, 0 failed.
---
@@ -96,7 +102,7 @@ Optionally add a unit test to `AbLegacyEquipmentTagTests` covering
| Severity | Low |
| Category | OtOpcUa conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs:10` |
| Status | Open |
| Status | Resolved |
**Description:** `AbLegacyPlcFamilyProfile.MaxTagBytes` is a record constructor parameter
populated with distinct values per family (240/232/240/240), but a global search finds zero
@@ -111,7 +117,11 @@ produce off-by-one sizing. If reserved for future clamping, the intent is undocu
clamping and is not currently enforced, or (b) remove the field and its four initialisers
in a dedicated cleanup PR (signature change is out of scope for this review).
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20. Applied option (a): added XML doc on the `MaxTagBytes`
constructor parameter in `AbLegacyPlcFamilyProfile.cs` noting it is reserved for future
array-length clamping, is NOT currently enforced anywhere in the driver, and that the values
are approximate PCCC packet payload caps (not libplctag fragment limits). Field and
initialisers unchanged.
---
@@ -122,7 +132,7 @@ in a dedicated cleanup PR (signature change is out of scope for this review).
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs:22` |
| Status | Open |
| Status | Resolved |
**Description:** `AbLegacyPlcFamilyProfile.ForFamily` has a catch-all arm `_ => Slc500`
that silently returns the SLC 500 profile for any unrecognised `AbLegacyPlcFamily` value
@@ -139,4 +149,8 @@ added), or (b) replace `_ => Slc500` with
`_ => throw new ArgumentOutOfRangeException(nameof(family), family, null)` and apply it
in a cleanup PR. Semantic change -- defer.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20. Applied option (a): added a `<remarks>` block to the
`ForFamily` XML doc in `AbLegacyPlcFamilyProfile.cs` documenting that any unrecognised
`family` value silently returns the `Slc500` profile, and noting this is intentional for
forward-compatibility with configs authored before a new family enum member is added. The
switch body is unchanged.
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -61,13 +61,13 @@ a category produced nothing rather than leaving it blank.
| Severity | Low |
| Category | Design-document adherence |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs:139-145` |
| Status | Open |
| Status | Resolved |
**Description:** `FocasTagDefinition` carries a `WriteIdempotent` field (default `false`) that the FOCAS driver never reads anywhere — neither in `DiscoverAsync`, `ReadAsync`, nor `WriteAsync`. `DiscoverAsync` always passes `WriteIdempotent: false` to `DriverAttributeInfo` (hardcoded), so the field has no runtime effect. `docs/drivers/FOCAS.md` does not mention `WriteIdempotent` in its configuration tables, making its purpose undiscoverable to operators.
**Recommendation:** Either (a) thread `FocasTagDefinition.WriteIdempotent` through to `DriverAttributeInfo` in `DiscoverAsync` so the field has runtime effect and matches the pattern of other drivers, or (b) remove it from the record and the `FocasDriverConfigDto` wire DTO. Option (a) is the safer fix and matches the design of Modbus and other drivers. This is deferred because the fix touches `FocasDriver.DiscoverAsync` outside this module and requires a driver-level test change.
**Resolution:** _(empty until closed)_
**Resolution:** Fixed (option a). In `FocasDriver.DiscoverAsync` the hardcoded `WriteIdempotent: false` was replaced with `tag.WriteIdempotent` so each user-authored tag's per-tag value is now threaded through to `DriverAttributeInfo`. The `<param name="WriteIdempotent">` doc on `FocasTagDefinition` was updated to state it is now threaded through `DiscoverAsync` to `DriverAttributeInfo`. A new test `DiscoverAsync_surfaces_WriteIdempotent_from_tag_definition` was added to `FocasDriverMediumFindingsTests` asserting both the `true` and `false` cases surface correctly. The full `Driver.FOCAS.Tests` suite (including `FocasPmcBitRmwTests` and `FocasReadWriteTests` write tests) passes.
---
+14 -5
View File
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -27,7 +27,7 @@ a category produced nothing rather than leaving it blank.
| 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | No issues found |
| 8 | Code organization & conventions | Driver.Galaxy.Browser-003 (Low): ResolveApiKey duplicated from GalaxyDriver with no sync mechanism |
| 8 | Code organization & conventions | Driver.Galaxy.Browser-003 (Low, Resolved): ResolveApiKey duplicated from GalaxyDriver; extracted to `GalaxySecretRef` in Driver.Galaxy.Contracts |
| 9 | Testing coverage | Driver.Galaxy.Browser-004 (Low): MapSecurityClass not unit-tested; pure static method with no gateway dependency |
| 10 | Documentation & comments | No issues found |
@@ -119,7 +119,7 @@ mirroring the existing pattern for `_client.DisposeAsync()`. Regression test
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` |
| Status | Open |
| Status | Resolved |
**Description:** `GalaxyDriverBrowser.ResolveApiKey` is a verbatim copy of
`GalaxyDriver.ResolveApiKey`. The comment acknowledges this and explains why the Browser
@@ -133,8 +133,17 @@ and emit a spurious warning.
which both `Driver.Galaxy` and `Driver.Galaxy.Browser` already reference. This is a
one-line addition to Contracts — no migration, no public-contract break.
**Resolution:** _(deferred — requires a change in the Galaxy.Contracts project, outside
this module's boundary; tracked for a future consolidation pass)_
**Resolution:** Resolved 2026-06-20 — closed by the same fix as the cross-referenced
Driver.Galaxy.Contracts-003. The resolver was extracted into a new
`public static class GalaxySecretRef` in `Driver.Galaxy.Contracts`
(`ResolveApiKey(string secretRef, ILogger? logger = null)`), which both Driver.Galaxy
and Driver.Galaxy.Browser already reference. `GalaxyDriverBrowser.BuildClientOptions`
now calls `GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger)` and its private
`ResolveApiKey` copy is deleted — eliminating the drift risk (a future `vault:` prefix
is now added in one place). Behaviour is preserved exactly: the Browser's `_logger` is
never null (defaults to `NullLogger`), so the literal-arm cleartext warning still fires.
The Browser project builds clean against the shared resolver. See
Driver.Galaxy.Contracts-003 for the full extraction details.
---
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -27,7 +27,7 @@ a category produced nothing rather than leaving it blank.
| 5 | Security | No issues found (ApiKeySecretRef is documented as an indirection reference; no defaults store a cleartext secret; no ToString override leaks the ref) |
| 6 | Performance & resource management | No issues found (pure records; no allocations, disposables, or resource lifetimes) |
| 7 | Design-document adherence | No issues found (records match CLAUDE.md Gateway/MxAccess/Repository/Reconnect section layout) |
| 8 | Code organization & conventions | Driver.Galaxy.Contracts-003 (Low, Open): ResolveApiKey helper duplicated between GalaxyDriver and GalaxyBrowseSession; Contracts is the natural home |
| 8 | Code organization & conventions | Driver.Galaxy.Contracts-003 (Low, Resolved): ResolveApiKey helper duplicated between GalaxyDriver and GalaxyBrowseSession; extracted to `GalaxySecretRef` in Contracts |
| 9 | Testing coverage | No issues found (no logic to test; pure data records with default values) |
| 10 | Documentation & comments | Driver.Galaxy.Contracts-001 (Low, Resolved): Internal code-review finding ID `(Driver.Galaxy-010)` in shipped XML doc |
@@ -108,7 +108,7 @@ note the minimum and that `EventPump` enforces it at construction. Verified by b
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` (duplicate) and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:472` (original) |
| Status | Open |
| Status | Resolved |
**Description:** `GalaxyDriver.ResolveApiKey` (the four-form `env:`/`file:`/`dev:`/literal
resolver, ~45 LOC) is duplicated verbatim as `GalaxyDriverBrowser.ResolveApiKey`. The
@@ -135,6 +135,20 @@ which ships in-box with .NET 10's BCL and adds no new NuGet dependency.
overload) into a `GalaxySecretRef` static class in this project; update both call sites
to delegate to it.
**Resolution:** _(deferred — cross-module coordination change; Driver.Galaxy and
Driver.Galaxy.Browser must both be updated in the same commit. Tracked for a future
consolidation pass. See also Driver.Galaxy.Browser-003.)_
**Resolution:** Resolved 2026-06-20 — extracted the four-form resolver into a new
`public static class GalaxySecretRef` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs`,
namespace `ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config`) as a single
`ResolveApiKey(string secretRef, ILogger? logger = null)` method (the two former
`GalaxyDriver` overloads collapsed into one optional-logger signature). The exact
resolution semantics are preserved byte-for-byte: `env:NAME` (throws when unset),
`file:PATH` (throws when missing/empty, trims), `dev:KEY` (literal, no warning), and
the back-compat literal arm (returns the literal and emits the same `Warning` when a
logger is supplied). The Contracts `.csproj` gained a single
`Microsoft.Extensions.Logging.Abstractions` PackageReference for the `ILogger`
parameter. Both call sites now delegate: `GalaxyDriver.BuildClientOptions` calls
`GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger)` and the two private
`GalaxyDriver.ResolveApiKey` overloads are deleted; `GalaxyDriverBrowser.BuildClientOptions`
likewise delegates (passing its non-null `_logger`) and its private copy is deleted. No
migration, no public wire-contract change. Regression coverage: `GalaxyDriverApiKeyResolverTests`
was repointed at `GalaxySecretRef.ResolveApiKey` (all 10 facts green). This finding and
the sibling Driver.Galaxy.Browser-003 are closed by this one extraction.
+23 -19
View File
@@ -7,7 +7,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -83,7 +83,7 @@ observation + single `_ownedRepositoryClient`, the `ResolveApiKey`
estimate, the O(1) `SubscriptionRegistry` indices, and the `ReinitializeAsync`
equivalent-config gate all survive intact.
One new finding (Driver.Galaxy-019, Low, Open-deferred). No Critical / High /
One new finding (Driver.Galaxy-019, Low, Resolved 2026-06-20). No Critical / High /
Medium new findings. Category results:
| # | Category | Result |
@@ -91,12 +91,12 @@ Medium new findings. Category results:
| 1 | Correctness & logic bugs | No new issues found |
| 2 | OtOpcUa conventions | No new issues found |
| 3 | Concurrency & thread safety | No new issues found |
| 4 | Error handling & resilience | Driver.Galaxy-019 |
| 4 | Error handling & resilience | Driver.Galaxy-019 (Resolved) |
| 5 | Security | No new issues found (vendoring retired; no secret logging; `ResolveApiKey` warns on cleartext) |
| 6 | Performance & resource management | No new issues found |
| 7 | Design-document adherence | No new issues found |
| 8 | Code organization & conventions | No new issues found |
| 9 | Testing coverage | No new issues found (31 suites; `GatewayGalaxySubscriber` untestable per Driver.Galaxy-019) |
| 9 | Testing coverage | No new issues found (31 suites; `GatewayGalaxySubscriber` now has its first unit test — `GatewayGalaxySubscriberClassifyTests` for the Driver.Galaxy-019 classifier) |
| 10 | Documentation & comments | No new issues found |
## Findings
@@ -441,7 +441,7 @@ the existing csproj works correctly.
| Severity | Low |
| Category | Error handling & resilience |
| Location | `Runtime/GatewayGalaxySubscriber.cs:89-100` |
| Status | Open |
| Status | Resolved |
**Description:** `GatewayGalaxySubscriber.EnsureSessionIntervalAsync` applies
the session-level `SetBufferedUpdateInterval` command and then caches the
@@ -479,17 +479,21 @@ extract the reply→outcome classification into a pure internal helper
the sealed, internal-ctor `MxGatewaySession``GatewayGalaxySubscriber`
currently has zero unit tests for exactly this reason.
**Resolution:** Deferred 2026-06-19the fix is small but cannot be landed
with a real TDD red→green cycle in this module: the behaviour lives entirely
behind the sealed `MxGatewaySession` (no public/internal ctor; the same
constraint that forced every other gw call onto an injectable `IGalaxy*` seam),
so there is no way to drive a synthetic `MxCommandReply` through
`EnsureSessionIntervalAsync` without first refactoring out the pure-classifier
helper the recommendation calls for. That refactor plus giving the subscriber a
logger is a low-risk but non-trivial change to the production gw session path,
and the underlying intent (is caching on `MxaccessFailure` deliberate "the
gateway processed it, don't retry" behaviour, or an oversight?) is a design
question better confirmed against the gateway contract than guessed at in a
review sweep. Tracked for a follow-up that lands the classifier extraction +
its unit tests together. Impact is bounded to a sub-optimal (not broken)
publish cadence that self-heals on reconnect.
**Resolution:** Resolved 2026-06-20extracted the pure classifier
`internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok`
and routed `EnsureSessionIntervalAsync` through it, so `_lastAppliedIntervalMs`
is now set **only** on `Ok`. `MxaccessFailure` (COM-side set did not apply), any
other unexpected code, and a missing status all leave the cache untouched, so the
requested cadence is re-attempted on the next subscribe rather than permanently
pinned at the gateway default after a transient hiccup. The subscriber gained an
optional `ILogger? logger = null` ctor parameter (defaulting to `NullLogger.Instance`),
threaded through from `BuildProductionRuntimeAsync`'s `_logger` at the one
`new GatewayGalaxySubscriber(_ownedMxSession, _logger)` call site; it now emits a
`Warning` on both the `MxaccessFailure` soft-failure path and the unexpected-code
early-return path (no secret/key is logged), so the comment's promised signal exists.
Regression coverage: new `GatewayGalaxySubscriberClassifyTests`
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/`) — the subscriber's first
unit test — pins `Ok → true`, `MxaccessFailure → false`, `Unspecified → false`,
`null → false`. The sealed `MxGatewaySession` ctor that previously blocked a TDD
cycle is now sidestepped because the classifier is a pure static helper testable in
isolation.