# 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>>`. 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(, 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` 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();` (match the field/property names actually in `WireFocasClient`). Rewrite the now-inaccurate `` 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 == : 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 `` + 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 `/// Per-event status parallel to Events: 0=Ack, 1=Retry, 2=Permanent. Empty ⇒ older sidecar; fall back to PerEventOk.` `[Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty();`. **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 `` (`: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 (`.Status.Code`, `.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).