# Phase 4 (data-type tier) — Driver data-type & robustness coverage — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. **Goal:** Close five cleanly-achievable scalar data-type & robustness gaps from `stillpending.md` §2 across three disjoint driver projects (Modbus, FOCAS, Historian/AlarmHistorian). **Architecture:** Per-driver-independent changes; no contract churn, no EF migration, no `IOpcUaAddressSpaceSink` touch. `DriverDataType.Int64`/`UInt64` already exist + already resolve correctly on the equipment-node path. S7 wide types and all array work are deferred (see design doc). **Tech Stack:** .NET 10, xUnit + Shouldly (TDD red→green, **no bUnit**), Akka not touched, SQLite store-and-forward (item 5). **Design:** `docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes-design.md` (branch `feat/stillpending-phase-4-driver-datatypes` off master `7eeb9fb0`). **Hard rules:** stage by path — never `git add .`; never stage `sql_login.txt` / `src/Server/.../Host/pki/` / `pending.md` / `current.md` / `docker-dev/docker-compose.yml` / `stillpending.md`; never echo/commit secrets; no force-push; no `--no-verify`; NO Configuration entity / EF migration. --- ### Task 0: Feature branch — DONE Branch `feat/stillpending-phase-4-driver-datatypes` created off master `7eeb9fb0`; design doc committed `57d9f1b3`. No action. --- ### Task 1: Modbus Int64 / UInt64 node DataType **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 2, Task 4, Task 5 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs` (`MapDataType` ~1508-1523; stale doc ~1496-1506) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs` **Context:** `DriverDataType.Int64`/`UInt64` already exist and `OtOpcUaNodeManager.ResolveBuiltInDataType` already maps `"Int64"`/`"UInt64"`. The wire codec round-trips `long`/`ulong` correctly — only `MapDataType` lies (returns `Int32`). **Step 1 — Failing test.** In `ModbusDataTypeTests.cs` add: ```csharp [Theory] [InlineData(ModbusDataType.Int64, DriverDataType.Int64)] [InlineData(ModbusDataType.UInt64, DriverDataType.UInt64)] public void MapDataType_64bit_surfaces_correct_DriverDataType(ModbusDataType wire, DriverDataType expected) => ModbusDriver.MapDataType(wire).ShouldBe(expected); ``` (If `MapDataType` is `private`, either make it `internal` + ensure `[InternalsVisibleTo]` to the test project already exists — it does for `DecodeRegister`/`EncodeRegister` tests — or assert via a `DiscoverAsync` recording-builder test that an `Int64` tag's `DriverAttributeInfo.DriverDataType == DriverDataType.Int64`. Prefer matching the existing visibility pattern in this file.) **Step 2 — Run, expect FAIL** (`Int64` currently maps to `Int32`): `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter MapDataType_64bit` **Step 3 — Implement.** In `MapDataType` replace the combined line: ```csharp ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, ``` with: ```csharp ModbusDataType.Int64 => DriverDataType.Int64, ModbusDataType.UInt64 => DriverDataType.UInt64, ``` Remove the stale `Driver.Modbus-007` XML-doc caveat block (~1496-1506) and the inline `// Driver.Modbus-007 …` comment (~1510-1516). Leave `Bcd16/Bcd32 => Int32` and the `_ => Int32` default unchanged. **Step 4 — Run, expect PASS** + the full Modbus suite green: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests` **Step 5 — Commit:** ```bash git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs git commit -m "fix(modbus): surface Int64/UInt64 node DataType (Driver.Modbus-007)" ``` --- ### Task 2: FOCAS fail-fast factory **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Task 1, Task 4, Task 5 (NOT Task 3 — both edit `FocasDriver.cs`) **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs` (`IFocasClientFactory`, `UnimplementedFocasClientFactory`, `WireFocasClientFactory`) - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` (`InitializeAsync`) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs` **Context:** `UnimplementedFocasClientFactory.Create()` throws only on first read (lazy `Create()` at `FocasDriver.cs:1079`), so an `unimplemented`/`none`/`stub` backend reports **healthy** then faults every read. Make it fail at init. **Step 1 — Failing test.** In `FocasScaffoldingTests.cs` add a test asserting that a driver built on `Backend:"unimplemented"` throws (or transitions the driver Faulted, matching how this suite already asserts init failures) from `InitializeAsync`, BEFORE any `ReadAsync`. Mirror the existing scaffolding-test setup. Assert the exception/health message contains `"unimplemented"`. **Step 2 — Run, expect FAIL** (today init succeeds; the throw only happens on first read): `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter Scaffolding` **Step 3 — Implement.** - Add to `IFocasClientFactory`: `void EnsureUsable();` (a config-time usability probe that must NOT create a live wire client). - `WireFocasClientFactory.EnsureUsable()` → no-op (`{ }`). - `UnimplementedFocasClientFactory.EnsureUsable()` → `throw new NotSupportedException()`. - In `FocasDriver.InitializeAsync`, after the factory is in hand and before the probe/poll loops start, call `_clientFactory.EnsureUsable();` (let it propagate so the driver faults at init with the actionable message). Keep `Create()`'s own throw as the lazy backstop. **Step 4 — Run, expect PASS** + full FOCAS suite green: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests` **Step 5 — Commit:** ```bash git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs \ src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs git commit -m "fix(focas): fail-fast at init on unimplemented backend (operator footgun)" ``` --- ### Task 3: FOCAS position scaling (config DecimalPlaces) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 1, Task 4, Task 5 (NOT Task 2 — both edit `FocasDriver.cs`; sequence after Task 2) **Files:** - Modify: the FOCAS device-config record (find it: `rg "record FocasDeviceOptions" src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/`; if config flows via a separate DTO `FocasDriverConfigDto`/device DTO, add the field there and thread it onto the device options) — add `PositionDecimalPlaces` (int, default `0`). - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` (`PublishAxisSnapshot` ~817-820) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs` (or a new `FocasPositionScalingTests.cs`) **Context:** `cnc_rddynamic2` returns scaled integers; the `10^dp` divide is never applied. Axis variables are already `DriverDataType.Float64` (`FocasDriver.cs:519`) → **no node-type change**. Scale only the four position fields; FeedRate/SpindleSpeed/ServoLoad are untouched. Default `0` = today's behaviour (backward compatible). **Step 1 — Failing test.** Assert: with the device configured `PositionDecimalPlaces:3`, a snapshot with `AbsolutePosition=12345` publishes `12.345d` (read it back via `ReadAsync` of the AbsolutePosition fixed-tree ref, mirroring `FocasReadWriteTests` setup which drives the poll loop / cache). Add a second case: `PositionDecimalPlaces:0` publishes `12345d` unchanged; and FeedRate is never scaled. **Step 2 — Run, expect FAIL.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter Scaling` **Step 3 — Implement.** - Add `PositionDecimalPlaces` (default 0) to the device options/DTO and thread it into `DeviceState.Options` (whatever `PublishAxisSnapshot` reads as `state.Options`). - In `PublishAxisSnapshot`, compute `var factor = state.Options.PositionDecimalPlaces > 0 ? Math.Pow(10, state.Options.PositionDecimalPlaces) : 1.0;` and store `snap.AbsolutePosition / factor` (etc.) for the **four position fields** (Absolute / Machine / Relative / DistanceToGo). Leave any FeedRate/SpindleSpeed publishes (and the `ServoLoad` field) unscaled. Publish as `double` (already Float64 nodes). - Guard a negative config value (treat `<0` as `0`, or validate in the config parse) so `Math.Pow` can't misbehave. **Step 4 — Run, expect PASS** + full FOCAS suite green: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests` **Step 5 — Commit:** ```bash git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ git commit -m "feat(focas): scale axis positions by 10^PositionDecimalPlaces (config-supplied)" ``` (Stage only the precise FOCAS files you edited — list them explicitly, do not blanket-add the dir if other work is present.) --- ### Task 4: Historian `Total` aggregate (client-side) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 1, Task 2, Task 3, Task 5 **Files:** - Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs` (`ReadProcessedAsync` ~102-130, `MapAggregate` ~513-522) - Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs` **Context:** `MapAggregate` throws for `Total` (Wonderware AnalogSummary has no Total column). For a time-weighted average, `Total = Average × interval-duration`. Compute client-side. **Step 1 — Replace the throws-test.** Remove/repurpose `ReadProcessedAsync_TotalAggregate_ThrowsNotSupported`. Add `ReadProcessedAsync_TotalAggregate_ReturnsAverageTimesIntervalSeconds`: FakeSidecarServer `OnReadProcessed`/`OnAnalogSummary` returns Average buckets (e.g. value `2.0`); request `Total` with `interval = TimeSpan.FromMinutes(1)` (60 s) → client returns `120.0` for that bucket; quality carries through. **Step 2 — Run, expect FAIL.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests --filter TotalAggregate` **Step 3 — Implement.** In `ReadProcessedAsync`: - Detect `aggregate == HistoryAggregateType.Total`. For the wire request, use `MapAggregate(HistoryAggregateType.Average)` as the `AggregateColumn` (i.e. substitute Average for the query). - After decoding the reply samples, when the original request was `Total`, multiply each sample's numeric value by `interval.TotalSeconds` (re-box at the same value type the Average decode produced; keep `StatusCode`/timestamps). Factor a small helper if cleaner. - Drop the `Total` throw from `MapAggregate` (or keep `MapAggregate` Average-only and never pass it `Total`). Add a `` note: "Total is derived client-side as time-weighted Average × interval-seconds; Wonderware AnalogSummary exposes no Total column." **Step 4 — Run, expect PASS** + full suite green: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests` **Step 5 — Commit:** ```bash git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs \ tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs git commit -m "feat(historian): support Total aggregate (client-side Average x interval-seconds)" ``` --- ### Task 5: Historian poison-event dead-letter cap **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 1, Task 2, Task 3, Task 4 **Files:** - Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs` (ctor ~119-135, `ReadBatch`/`QueueRow` ~555-586, drain outcome loop ~448-462) - Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/SqliteStoreAndForwardSinkTests.cs` - (If a wiring/options site passes the other knobs — `capacity`/`deadLetterRetention` — thread `maxAttempts` there too; grep `new SqliteStoreAndForwardSink(` and the `AddAlarmHistorian` DI to find call sites.) **Context:** All failures map to `RetryPlease`; the drain bumps `AttemptCount` but never caps it → poison events retry forever. The `AttemptCount` column already exists (schema `:23,689`); `QueueRow` just doesn't surface it. **Step 1 — Failing test.** In `SqliteStoreAndForwardSinkTests.cs`: a writer stub that always returns `RetryPlease`; enqueue one event; drain `maxAttempts` times (construct the sink with a small `maxAttempts`, e.g. 3). Assert after the 3rd drain the row is **dead-lettered** (`HistorianSinkStatus.DeadLetterDepth == 1`, `QueueDepth == 0`); assert before the cap it stays queued. **Step 2 — Run, expect FAIL** (today it never dead-letters): `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests --filter MaxAttempts` **Step 3 — Implement.** - Add `DefaultMaxAttempts` const (e.g. `10`) + `int maxAttempts = DefaultMaxAttempts` ctor param; store `_maxAttempts`; guard `> 0` (throw `ArgumentOutOfRangeException` like `batchSize`/`capacity`, or clamp with a `_logger.Warning`). Add the `` doc. - Extend `QueueRow` → `record struct QueueRow(long RowId, AlarmHistorianEvent? Event, long AttemptCount)`; add `AttemptCount` to the `ReadBatch` SELECT (`SELECT RowId, PayloadJson, AttemptCount …`) and read it (`reader.GetInt64(2)`). - In the drain outcome loop, `case HistorianWriteOutcome.RetryPlease:` → `if (liveRows[i].AttemptCount + 1 >= _maxAttempts) DeadLetterRow(conn, tx, rowId, $"max attempts ({_maxAttempts}) exceeded"); else BumpAttempt(conn, tx, rowId, "retry-please");` (and count a dead-lettered row as leaving the queue, mirroring the existing `PermanentFail` arm: `rowsLeavingQueue++`). **Step 4 — Run, expect PASS** + full suite green: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests` **Step 5 — Commit:** ```bash git add src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs \ tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/SqliteStoreAndForwardSinkTests.cs git commit -m "fix(historian): dead-letter poison events after maxAttempts (finding 002)" ``` --- ### Task 6: Docs + bookkeeping **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** none (run after Tasks 1-5 land) **Files:** - Modify: `docs/drivers/FOCAS.md` (fail-fast backend + `PositionDecimalPlaces` knob), the Modbus driver doc (Int64/UInt64 now surfaced), `docs/Historian.md` (Total aggregate now supported; poison-event dead-letter cap). - Modify (disk-only, do NOT commit): `pending.md`, `stillpending.md` annotations, memory `MEMORY.md` + `project_stillpending_backlog.md`. **Steps:** 1. Update each driver doc: Modbus Int64/UInt64 node-type now correct (remove the Driver.Modbus-007 caveat if mentioned); FOCAS unimplemented backend now fails at init + the `PositionDecimalPlaces` config field; Historian `Total` supported (note the Average×seconds derivation) + the `maxAttempts` dead-letter cap. 2. Find the exact doc paths (`rg -l "Driver.Modbus-007|PositionDecimalPlaces|AnalogSummary" docs/` + the FOCAS/Modbus/Historian guide docs). 3. Commit ONLY the `docs/**` files by path: ```bash git add docs/drivers/FOCAS.md docs/Historian.md git commit -m "docs(phase4): Int64/UInt64 modbus, FOCAS fail-fast+scaling, Historian Total+dead-letter" ``` 4. Update `pending.md` / `stillpending.md` / memory on disk (NOT staged) at the integration step. --- ### Task 7: Full build + test + final integration review **Classification:** high-risk (final gate) **Estimated implement time:** ~6 min **Parallelizable with:** none **Steps:** 1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect 0 errors (production projects are `TreatWarningsAsErrors`). 2. `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests` — all green. (Then a full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` if time allows.) 3. **Final integration reviewer subagent** over `git diff 57d9f1b3..HEAD`: verify (a) no contract/`IOpcUaAddressSpaceSink`/`DriverDataType` change leaked in; (b) Modbus codec unchanged (only `MapDataType`); (c) FOCAS scaling applies to positions only + default 0 is byte-identical to old behaviour; (d) Historian Total quality/timestamp carry-through is correct; (e) the dead-letter cap counts the row as leaving the queue (no `_queuedRowCount` drift) and `maxAttempts<=0` is guarded; (f) every commit staged by path, no forbidden file staged. 4. Address any blocking findings, re-review, then proceed. --- ### Task 8: Live `/run` — Modbus Int64 (the one live-verifiable item) **Classification:** high-risk (acceptance gate, agent-driven on docker-dev — login disabled) **Estimated implement time:** ~8 min **Parallelizable with:** none **Steps:** 1. Rebuild the docker-dev central nodes onto this branch: `docker compose -f docker-dev/docker-compose.yml up -d --build central-1 central-2` (do NOT stage the compose file). 2. Confirm healthy startup + clean deploy in the logs (`applied plan …`, OPC UA serving). 3. Author (or seed) a Modbus equipment tag of type Int64 bound to the modbus sim (`10.100.0.35:5020`), deploy, and via Client.CLI confirm the node **advertises Int64** and a value outside the 32-bit range reads back correctly (browse the node's DataType + read). Drive the AdminUI/`/uns` if authoring through the UI. 4. FOCAS (no CNC) and Historian (sidecar-gated on `10.100.0.48`) live gates: record honestly as unit-proven / deferred — do not fabricate a pass. 5. Capture the evidence in the completion report. --- ## Execution notes (subagent-driven) - **Dispatch order:** Tasks 1, 2, 4, 5 are mutually parallelizable (disjoint projects) — dispatch their implementers concurrently. Task 3 follows Task 2 (both edit `FocasDriver.cs`). Task 6 after 1-5; Task 7 then Task 8. - **Review chain by classification:** small → code-reviewer (Sonnet/Haiku); standard → spec ∥ code reviewers; Task 7 → final integration reviewer. Implementer model: opus for standard (3,4,5), sonnet for small (1,2,6). - Done = build clean + `dotnet test` green + final review SHIP + the Modbus live gate, then finish-a-development-branch → **merge to master + push**.