# FOCAS cnc_getfigure + Wonderware poison-event status + AbCip nested-UDT live-gate — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.

**Goal:** Close three backlog items — FOCAS `cnc_getfigure` wire command (#3), Wonderware poison-event per-event status (#5), AbCip nested-UDT Emulate-tier live-gate (#6).

**Architecture:** Three disjoint-project components. A: a new FOCAS/2 wire command in `FocasWireClient` + matching `focas_mock` handler, wiring the `WireFocasClient` stub. B: an additive `[Key(4)] PerEventStatus` field on the in-repo Wonderware sidecar IPC reply + a sidecar structural-malformed classifier + client consumption. C: a skip-gated Emulate-tier AbCip nested-UDT integration test + docs.

**Tech Stack:** .NET 10 (FOCAS, AbCip) / .NET 4.8 sidecar (Wonderware), MessagePack IPC, python focas-mock, xUnit + Shouldly.

**Design:** `docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate-design.md` (committed `a687821f`).

**Standing constraints:** NO Commons/proto change · NO Core.Abstractions/breaking-interface change (the sidecar `IAlarmEventWriter` is NOT touched) · NO EF migration · NO bUnit · stage by explicit path (never `git add .`) · never stage `sql_login.txt`/`pki/`/`pending.md`/`current.md`/`stillpending.md`/`docker-dev/docker-compose.yml` · never echo/commit secrets · no force-push / no `--no-verify` · `dangerouslyDisableSandbox:true` for all build/test/rig commands · finish = merge to master + push.

---

### Task 1: FOCAS `cnc_getfigure` wire command + sim handler

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3

**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs` (add `ReadPositionFiguresAsync`, mirror `ReadServoMeterAsync` at :351-386)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs:287-297` (replace the empty-list stub)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py` (add `_wire_position_figures` + a `_wire_payload` branch for the chosen command code)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/data_store.py` (add a default `position_figures` state key)

**Context:** `WireFocasClient` is the production path to real CNCs. `FocasDriver.cs:662` wraps the fetch in `SafeProbe(…, [])` and `AxisFactor` uses a figure only when present & ≥ 0 — so an empty/failed read degrades to today's config-knob behavior (cannot regress). The `.NET FocasWireClient` ↔ python `focas_mock` wire protocol is co-designed & sim-validated (bench-CNC validation is gated for the whole backend — out of scope). `IFocasClient.GetPositionFiguresAsync` already exists — **no interface change.**

**Steps:**
1. **`FocasWireClient.ReadPositionFiguresAsync(CancellationToken, TimeSpan? timeout, ushort? pathId)`** → `Task<FocasResult<IReadOnlyList<int>>>`. Pick a wire command id **unused by both the client and the mock's `_wire_payload`** (e.g. `0x00D3`); add an XML-doc note that the code is sim-consistent and bench-CNC-unvalidated. Send `new RequestBlock(<code>, PathId: requestPathId)`; on `rc != 0` return empty. Parse the response payload as consecutive big-endian `short dec` values (`payload.Length / 2` axes) into a `List<int>` via the existing `ReadInt16` helper. Keep it self-delimiting (no axis-name pairing needed).
2. **`WireFocasClient.GetPositionFiguresAsync`** — replace the stub body: `var r = await _client.ReadPositionFiguresAsync(cancellationToken).ConfigureAwait(false); return r.Rc == 0 && r.Value is not null ? r.Value : Array.Empty<int>();` (match the field/property names actually in `WireFocasClient`). Rewrite the now-inaccurate `<returns>` doc to describe the live fetch + graceful-empty fallback. Never throws.
3. **focas_mock `data_store.py`** — add a `position_figures` key to the default state: a per-axis decimal-place map (or list parallel to `axis_names`), **defaulting every axis to 0** (→ scale factor 1.0 → no change to existing integration assertions). Make it `mock_patch`-targetable.
4. **focas_mock `server.py`** — add `def _wire_position_figures(self) -> bytes:` returning `b"".join(self._u16(dec) for each axis in axis_names order)` reading from `position_figures` (default 0). Add a branch in `_wire_payload`: `if command == <code>: return self._wire_position_figures()`.
5. **Build:** `dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS` — clean.
6. **Commit** (stage by explicit path): the two `.cs` files + the two `.py` files.

**Acceptance:** stub gone; client method follows the `ReadServoMeterAsync` pattern; mock returns per-axis figures defaulting to 0; FOCAS project builds clean. (Integration proof is Task 4.)

---

### Task 2: Wonderware poison-event per-event status (sidecar IPC + classifier + client)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 3

**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs` (add `[Key(4)] byte[] PerEventStatus` to `WriteAlarmEventsReply`, ~:216-228)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Contracts.cs` (mirror the same `[Key(4)]` addition, ~:252-266)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/HistorianFrameHandler.cs:162-199` (classifier + populate both fields)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs:301-369` (consume `PerEventStatus`; rewrite stale `<remarks>` + inline comments)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/` (new file, e.g. `HistorianEventClassifierTests.cs`) — sidecar classifier unit test (project has `InternalsVisibleTo`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs` (client mapping + back-compat via `FakeSidecarServer.OnWriteAlarmEvents`)

**Context:** Both IPC ends are in-repo (MessagePack/TCP — **not** Commons). `HistorianWriteOutcome` already has `PermanentFail`; the sink (`SqliteStoreAndForwardSink.cs:456-465`) already dead-letters it. The sidecar `IAlarmEventWriter.WriteAsync` returns only `bool[]` — **do NOT change that interface** (its only real impl is the infra-gated `AahClientManagedAlarmEventWriter`). `AlarmHistorianEventDto` fields: `EventId`, `SourceName` (req), `ConditionId?`, `AlarmType` (req), `Message?`, `Severity`, `EventTimeUtcTicks`, `AckComment?`.

**Steps:**
1. **DTO (both Contracts.cs, identical):** add `/// <summary>Per-event status parallel to Events: 0=Ack, 1=Retry, 2=Permanent. Empty ⇒ older sidecar; fall back to PerEventOk.</summary>` `[Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty<byte>();`. **Keep `PerEventOk [Key(3)]`.** Define the 3 byte constants once on each side (e.g. `const byte StatusAck=0, StatusRetry=1, StatusPermanent=2;` local to the consumer, or a tiny shared-by-copy comment — do NOT add a Core.Abstractions enum).
2. **Sidecar `HistorianFrameHandler.HandleWriteAlarmEventsAsync`:** add a pure `internal static byte[] ClassifyAndSplit(AlarmHistorianEventDto[] events, out List<(int index, AlarmHistorianEventDto evt)> writable)` (or equivalent): an event with `string.IsNullOrWhiteSpace(SourceName)` OR `string.IsNullOrWhiteSpace(AlarmType)` OR `EventTimeUtcTicks <= 0` ⇒ **Permanent (2)** and **excluded** from the writer batch; others ⇒ writer-bound. Call `_alarmWriter.WriteAsync(writableEvents)`; map each writer `true`→Ack(0), `false`→Retry(1) back into its original index. Set `reply.PerEventStatus` (full length) AND `reply.PerEventOk` (Ack→true else false) AND `reply.Success = true`. In the no-writer + catch branches, set `PerEventStatus` to all-Retry(1) (matching the existing all-false PerEventOk).
3. **Client `WriteBatchAsync`:** after the `reply.Success` guard, if `reply.PerEventStatus is { Length: > 0 } st && st.Length == batch.Count`, map `0→Ack, 1→RetryPlease, 2→PermanentFail` (unknown→RetryPlease); else keep the existing `PerEventOk` loop. Rewrite the stale `<remarks>` (`:301-311`) + the two inline "PermanentFail is never emitted" comments to describe the new path + the documented SDK-semantic boundary.
4. **Sidecar test:** `ClassifyAndSplit` — malformed (empty SourceName / empty AlarmType / EventTimeUtcTicks ≤ 0) ⇒ Permanent + excluded; valid ⇒ writer-bound; verify index mapping with a mixed batch.
5. **Client test:** (a) `OnWriteAlarmEvents` returns `PerEventStatus=[2]` ⇒ `WriteBatchAsync` yields `PermanentFail`; (b) empty `PerEventStatus` + `PerEventOk=[false]` ⇒ `RetryPlease` (back-compat); (c) `PerEventStatus=[0]` ⇒ `Ack`.
6. **Build + test:** `dotnet build` the two src projects (note: sidecar targets net48 — build via the solution or the specific TFM) + `dotnet test` the two test projects (filter to the new tests).
7. **Commit** (explicit paths): 4 src files + 2 test files.

**Acceptance:** additive `[Key(4)]` on both ends; sidecar classifies structurally-malformed events as Permanent and excludes them; client emits `PermanentFail` from `PerEventStatus` and falls back to `PerEventOk`; stale comments gone; tests green. No `IAlarmEventWriter` / Commons / Core.Abstractions change.

---

### Task 3: AbCip nested-UDT Emulate-tier skip-gated test + docs

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2

**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs`
- Modify: `docs/drivers/AbCip.md` (nested-struct = Emulate-tier verified note, ~:136)

**Context:** Decode/threading already shipped (`3d8ce4e8`/`d203f31c`); offline unit tests pin it (301/301). `ab_server` can't serve CIP Template Object (class 0x6C) → a local gate is impossible; the honest close is an Emulate-tier skip-gated smoke. Mirror `Emulate/AbCipEmulateUdtReadTests.cs` exactly (same `[AbServerFact]` + `AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate)` + `AB_SERVER_ENDPOINT` env, `Emulate = "emulate"`).

**Steps:**
1. Read `Emulate/AbCipEmulateUdtReadTests.cs` + `AbServerProfileGate.cs` + `AbServerFixture.cs` for the exact collection/attribute/skip pattern.
2. Write `AbCipEmulateNestedUdtTests` with one `[AbServerFact]` test: `SkipUnless(Emulate)`, read `AB_SERVER_ENDPOINT`, init an `AbCipDriver` with `EnableControllerBrowse=true`, run `DiscoverAsync` against a documented nested-UDT Emulate project (parent UDT with a nested struct member), assert the nested atomic leaves are addressable (`<Tag>.Status.Code`, `<Tag>.Status.Running`) and the nested sub-folder materializes. Document the assumed Emulate project shape in an XML/comment header (matching the `AbCipEmulateUdtReadTests` convention).
3. `docs/drivers/AbCip.md`: add/extend the nested-struct paragraph — support **shipped** (`3d8ce4e8`/`d203f31c`), **live-verified Emulate-tier only** because `ab_server` lacks CIP Template Object; reference `AbCipEmulateNestedUdtTests` + the offline unit coverage.
4. **Build + skip-check:** `dotnet build` the AbCip.IntegrationTests project (clean); run the new test by name and confirm it **skips** cleanly on this Mac (no Emulate): `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter FullyQualifiedName~Nested_struct -- … ` → 0 failed, 1 skipped.
5. **Commit** (explicit paths): the new test file + `docs/drivers/AbCip.md`.

**Acceptance:** the test compiles & skips locally (proving it's wired); docs describe the Emulate-tier gate honestly. No runtime change.

---

### Task 4: FOCAS integration test + Component A live `/run`

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (after Task 1)

**Files:**
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs` (new skip-gated test)

**Steps:**
1. Read `Series/WireBackendTests.cs` + `Series/WireBackendCoverageTests.cs` + `FocasSimFixture.cs` for the `[Collection(FocasSimCollection.Name)]` + `if (_fx.SkipReason is not null) Assert.Skip(...)` + `mock_patch`/`LoadProfileAsync` pattern.
2. Add `Position_figures_scale_axis_via_wire_backend`: load a profile, `mock_patch` axis_names `["X"]`, a dynamic `pos.absolute = 12345`, and `position_figures` so X = 3 decimal places; init the wire-backed `FocasDriver` (FixedTree enabled, probe disabled); `WaitFor` the `AbsolutePosition` snapshot; assert it equals **12.345** (raw ÷ 10^3) — i.e. the auto figure won, not the config knob.
3. **Live `/run` (Component A):** bring the focas-mock up locally and run the FOCAS integration suite:
   ```
   docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml up -d --build
   dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests --filter FullyQualifiedName~Position_figures_scale
   ```
   Confirm the new test **executes (not skipped)** and passes. Tear the mock down after.
4. **Commit** (explicit path): the integration test file.

**Acceptance:** the new integration test runs against the live focas-mock and proves the scaled position end-to-end.

---

### Task 5: Reconcile backlog + memory + finish (merge + push)

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (last)

**Files:**
- Modify (NEVER staged): `stillpending.md` (mark #3/#5/#6)
- Modify: memory `project_stillpending_backlog.md` + `MEMORY.md` index line (under the memory dir)

**Steps:**
1. **Full solution build:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — 0 errors.
2. **Targeted tests green:** Driver.FOCAS.Tests + Wonderware.Tests + Wonderware.Client.Tests + Core.AlarmHistorian.Tests + AbCip.IntegrationTests (new test skips). Report counts.
3. **`stillpending.md`** (never staged): #3 FOCAS — wire `cnc_getfigure` shipped + focas-mock-proven (real-Fanuc bench-CNC-gated, graceful fallback); #5 Wonderware — structurally-malformed poison events now `PermanentFail` → immediate dead-letter (additive `PerEventStatus`; SDK-semantic boundary noted as follow-up); #6 AbCip — live-gate formalized as the Emulate-tier skip-gated `AbCipEmulateNestedUdtTests`.
4. **Memory:** update `project_stillpending_backlog.md` (new top marker for this 3-item phase) + the `MEMORY.md` index line (keep ≤ ~200 chars; trim if over the soft cap).
5. **Finish** (superpowers-extended-cc:finishing-a-development-branch): verify tests → ff-merge `feat/focas-figure-ww-poison-abcip-gate` → master → push to origin → delete branch → confirm `local master = origin/master`.

**Acceptance:** build clean, tests green, backlog + memory reconciled, merged to master + pushed.

---

## Dependency graph

`{T1 ∥ T2 ∥ T3}` → `T4` (needs T1) → `T5` (last). T1/T2/T3 touch disjoint projects → dispatch implementers concurrently with **worktree isolation** (shared-tree git-race lesson).
