# 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(<same message as Create()>)`.
- 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 `<remarks>` 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 `<param>` 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 <modbus-doc-path>
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**.
