# Code Review — Driver.Modbus.Contracts

<!-- Per-module findings file. See ../../REVIEW-PROCESS.md for the full process.
     The base README.md is generated from these files by regen-readme.py — do not
     edit README.md by hand. -->

| Field | Value |
|---|---|
| Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts` |
| Reviewer | Claude Code |
| Review date | 2026-06-19 |
| Commit reviewed | `a19b0f86` |
| Status | Reviewed |
| Open findings | 0 |

## Checklist coverage

| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | 2 findings (001, 002) — both fixed in this review |
| 2 | OtOpcUa 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 | No issues found |
| 9 | Testing coverage | No dedicated test project. The pre-declared-tag parse path is covered in Driver.Modbus.Tests; the equipment-tag TryParse path has no unit tests — deferred. |
| 10 | Documentation & comments | 2 findings (003, 004) — both fixed in this review |

## Findings

### Driver.Modbus.Contracts-001

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs:35` |
| Status | Resolved |

**Description:** TryParse cast the JSON "bitIndex" value to byte with no range check. ModbusTagDefinition documents the valid range as 0-15 (LSB-first within a 16-bit register). When a user typed a BitIndex >= 16 in the AdminUI numeric input, the byte was passed unmodified to the driver. ModbusDriver computes (raw & (1 << tag.BitIndex)) != 0 for reads and (ushort)(current | (1 << bit)) for writes. Because C# int shifts are modulo-32, 1 << 16 = 0x10000; masking against a ushort read value always produces 0 (read always returns false) and the write OR has no effect on the register. Correct data flow is silently lost for any BitInRegister equipment tag whose bitIndex is 16 or greater. ModbusTagConfigModel.Validate() returned null unconditionally so the AdminUI save path did not gate this. (The grammar parser ModbusAddressParser.TryParse correctly rejects bit indices > 15 on line 247, but equipment tags bypass the grammar parser.)

**Recommendation:** Add a range guard in TryParse immediately after reading bitIndex: `if (dataType == ModbusDataType.BitInRegister && bitIndex > 15) return false;`

**Resolution:** Fixed in this review (2026-06-19). Added guard in ModbusEquipmentTagParser.TryParse after the bitIndex read. The parser now returns false (BadNodeIdUnknown at the driver) rather than silently producing a broken definition. Build verified (0 errors, 0 warnings).

---

### Driver.Modbus.Contracts-002

| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs:36` |
| Status | Resolved |

**Description:** TryParse read "stringLength" via ReadInt (returning 0 when absent) and constructed a ModbusTagDefinition with StringLength = 0 even when DataType = String. No equivalent of ModbusDriverFactoryExtensions.ValidateStringLength was applied on the equipment-tag path. At runtime RegisterCount computes (0 + 1) / 2 = 0 (integer division), the driver issues a 0-register FC03/FC04 PDU, the PLC responds with Modbus exception 03 (Illegal Data Value), and the tag surfaces a misleading BadCommunicationError OPC UA status. ModbusTagConfigModel.Validate() returned null unconditionally, so the AdminUI save path also did not guard against this.

**Recommendation:** Add `if (dataType == ModbusDataType.String && stringLength < 1) return false;` in TryParse.

**Resolution:** Fixed in this review (2026-06-19). Added guard after the stringLength read, analogous to the existing Driver.Modbus-009 / ValidateStringLength protection in the pre-declared tag path. Build verified (0 errors, 0 warnings).

---

### Driver.Modbus.Contracts-003

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs:40` |
| Status | Resolved |

**Description:** MaxRegistersPerRead carried the comment "Setting to 0 disables the cap (discouraged — the spec upper bound still applies)". This is inaccurate and self-contradictory: setting the value to 0 causes the ReadPlanner to substitute the Modbus-spec maximum of 125, not to remove the cap. An operator reading "disables the cap" could reasonably expect unlimited chunk sizes.

**Recommendation:** Replace "disables the cap" with precise language stating that 0 resets to spec maximum.

**Resolution:** Fixed in this review (2026-06-19). Replaced the comment with language explaining that 0 resets to the spec maximum (125) and noting that values above 125 are spec-illegal for conforming servers. Build verified.

---

### Driver.Modbus.Contracts-004

| Field | Value |
|---|---|
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs:53-60` |
| Status | Resolved |

**Description:** MaxCoilsPerRead is typed ushort (0-65535) but the Modbus spec limits FC01/FC02 to 2000 coils per request. The comment only warned against 0; it did not warn that values above 2000 are spec-illegal. Setting MaxCoilsPerRead = 2001 caused the ReadPlanner to issue a single PDU requesting 2001 coils, which most PLCs reject with exception 03. No [Range] attribute was present to constrain Options binding, unlike ProbeTimeoutSeconds which already carries [Range(1, 60)]. The comment also used the inaccurate "disables the cap" phrasing (same root issue as 003).

**Recommendation:** Add [Range(0, 2000)] and clarify the 0-means-spec-maximum semantics in the doc.

**Resolution:** Fixed in this review (2026-06-19). Added [Range(0, 2000)] attribute to MaxCoilsPerRead and updated the comment to explain that 0 resets to spec maximum and that values above 2000 are spec-illegal. Build verified (0 errors, 0 warnings).
