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

**Date:** 2026-06-18
**Branch:** `feat/focas-figure-ww-poison-abcip-gate` (off master `274ba2b1`)
**Backlog items:** `stillpending.md` §A #3 (FOCAS `cnc_getfigure`), #5 (Wonderware poison-event sidecar wire), #6 (AbCip nested-struct prod live-gate)

Three independent backlog items bundled into one phase. They touch disjoint projects
(Driver.FOCAS + its python sim / Driver.Historian.Wonderware{,.Client} + Core.AlarmHistorian /
Driver.AbCip.IntegrationTests + docs), so they are independently implementable and parallelizable.

---

## Standing constraints (in force)

- **NO Commons wire/proto contract change**, NO Core.Abstractions / breaking interface contract change,
  NO EF migration, NO bUnit (Razor proven only by live `/run` — N/A this phase, no Razor touched).
- Stage by explicit path, never `git add .`; never stage the never-stage files (`sql_login.txt`,
  `src/Server/.../Host/pki/`, `pending.md`, `current.md`, `stillpending.md`, `docker-dev/docker-compose.yml`).
- No force-push, no `--no-verify`. Never echo/commit secrets. Finish = merge to master + push.
- `dangerouslyDisableSandbox: true` for all build/test/rig commands.

---

## Component A — FOCAS `cnc_getfigure` wire command (backlog #3)

### The finding that makes this safe

`WireFocasClient` (the pure-managed FOCAS/2 Ethernet client) is the **production path to real CNCs**
(not the Fwlib P/Invoke path). Its `GetPositionFiguresAsync` is a hard-coded empty-list stub
(`Wire/WireFocasClient.cs:287-297`). The consumer wraps it in a graceful probe:

```csharp
// FocasDriver.cs:662
state.PositionFigures = await SafeProbe(() => client.GetPositionFiguresAsync(ct), []);
```

and `AxisFactor` (`FocasDriver.cs:~853`) uses a per-axis figure **only when present and ≥ 0**, else falls
back to the `PositionDecimalPlaces` config knob. So a wire command that errors or returns empty degrades to
**exactly today's behavior** — implementing it is monotonic and cannot regress real hardware.

The `.NET FocasWireClient` and the python `focas_mock` (`tests/.../FOCAS.IntegrationTests/Docker/focas-mock/`)
speak a **co-designed, internally-consistent** wire protocol: the mock dispatches on command codes in
`server.py:_wire_payload` (0x56=servo-meter, 0x26=axis position, 0x89=axis names, 0x120=timer, …). The
protocol is validated **against the sim**, not against real Fanuc hardware — that validation is bench-CNC-gated
for the *entire* wire backend (`docs/v2/implementation/focas-wire-protocol.md`), not unique to this command.

### Approach (driver-internal + sim; NO interface change — `IFocasClient.GetPositionFiguresAsync` already exists)

1. **`FocasWireClient.ReadPositionFiguresAsync(...)`** — mirror `ReadServoMeterAsync` (`FocasWireClient.cs:351-386`):
   send the figure request **paired with the axis-name request (`0x0089`)** so figures align positionally to
   axes (exactly how servo-meter pairs `0x0056`+`0x0089`). Pick a wire command id **currently unused by both
   the client and the mock** (the mock uses 0x0E/0x10/0x15/0x16/0x18/0x19/0x1A/0x1C/0x1D/0x23/0x24/0x25/0x26/
   0x35/0x40/0x56/0x57/0x89/0x8A/0x98/0xE1/0xFC/0x120/0x8001/0x8002 — choose a clearly-unused code, e.g. `0x00D3`,
   and document it as sim-consistent / bench-CNC-unvalidated). Response payload = per-axis `short dec`
   (decimal-place count); parse into `IReadOnlyList<int>`. `rc != 0` → empty list (graceful).
2. **`WireFocasClient.GetPositionFiguresAsync`** — replace the stub: call the new client method, return its list;
   any failure path returns empty (never throws — the `SafeProbe` + per-axis fallback contract is preserved).
   Rewrite the now-stale `<returns>` doc.
3. **`focas_mock`** — add a `cnc_getfigure` admin/handler entry + a `_wire_payload` branch for the chosen code
   returning per-axis `short dec` from a **new data-store key `position_figures`** (default **0 per axis** →
   `10^0 = 1.0` factor → no scaling → every existing integration assertion preserved). Register the method name
   in `constants.py:IMPLEMENTED_FOCAS_METHODS` + the server handler map + profile `exports` as needed.

### Testing

- **Driver consumption is already covered offline** by `FocasPositionAutoScaleTests` (FakeFocasClient returns
  figures → asserts scaled vs. fallback). No change needed there.
- **New end-to-end proof** = a skip-gated integration test in `Series/WireBackendCoverageTests.cs` (the
  established pattern, `[Collection(FocasSimCollection.Name)]` + `if (_fx.SkipReason is not null) Assert.Skip(...)`):
  `mock_patch` non-zero `position_figures`, init the wire-backed `FocasDriver`, assert the published
  `AbsolutePosition` is the **scaled** value (raw ÷ 10^dec), not the raw integer.
- **Live `/run` for A** = bring the focas-mock up locally on the Mac
  (`docker compose -f tests/.../FOCAS.IntegrationTests/Docker/docker-compose.yml up -d --build`) and run the
  FOCAS integration suite; the new test executes (does not skip) and passes.

### Honest boundary (documented, not built)

Real-Fanuc validation of the chosen wire code/payload stays bench-CNC-gated — same status as the whole wire
backend. Worst case on real hardware = graceful fallback to the config knob (i.e. no regression).

---

## Component B — Wonderware poison-event per-event status (backlog #5)

### The finding that shapes it

The sidecar IPC reply `WriteAlarmEventsReply.PerEventOk` is a `bool[]` on **both** ends — which are **both in
this repo** (`...Wonderware.Client/Ipc/Contracts.cs` + `...Wonderware/Ipc/Contracts.cs`, MessagePack over TCP;
**not** a Commons proto). The client (`WonderwareHistorianClient.WriteBatchAsync:340-369`) can therefore only
produce `Ack`/`RetryPlease`, never `PermanentFail`, so a poison event loops to the retry cap instead of
dead-lettering immediately. The `HistorianWriteOutcome` enum **already has `PermanentFail`** and the sink
(`SqliteStoreAndForwardSink.cs:456-465`) **already dead-letters it immediately**. The sidecar writer seam
`IAlarmEventWriter.WriteAsync` returns only `bool[]` and its sole real impl is the **infra-gated**
`AahClientManagedAlarmEventWriter` (AAH SDK).

### Approach (additive IPC field + sidecar classifier; NO `IAlarmEventWriter` change, NO Commons)

1. **Additive wire field (both Contracts.cs):** add `[Key(4)] byte[] PerEventStatus` to `WriteAlarmEventsReply`
   (0=Ack, 1=Retry, 2=Permanent). **Keep `PerEventOk [Key(3)]`** populated for rolling-deploy back-compat
   (new client ↔ old sidecar: empty Key(4) → fall back to PerEventOk; old client ↔ new sidecar: ignores Key(4)).
2. **Sidecar `HistorianFrameHandler.HandleWriteAlarmEventsAsync`:** add a pure `ClassifyEvents(events)` step —
   an event that is **structurally malformed** (empty `SourceName`, empty `AlarmType`, or `EventTimeUtcTicks <= 0`)
   can never persist → mark **Permanent** and **exclude it from the writer batch** (mirrors the client's existing
   corrupt-row exclusion). Remaining events go to the writer; `true`→Ack, `false`→Retry. Populate **both**
   `PerEventOk` (Ack→true else false) and `PerEventStatus`.
3. **Client `WriteBatchAsync`:** when `reply.PerEventStatus.Length == batch.Count`, map 0/1/2 →
   `Ack`/`RetryPlease`/`PermanentFail`; else fall back to the existing `PerEventOk` path. Rewrite the stale
   `<remarks>` + inline "PermanentFail is never emitted" comments.

### Testing (fully offline — no rig)

- Sidecar: pure `ClassifyEvents` unit test (malformed → Permanent + excluded; valid → delegated; writer
  false → Retry).
- Client: a `FakeSidecarServer` reply with `PerEventStatus=[2]` → `WriteBatchAsync` returns `PermanentFail`;
  a reply with empty `PerEventStatus` → falls back to `PerEventOk` (back-compat).
- End-to-end sink: an existing `SqliteStoreAndForwardSink` test already proves `PermanentFail` → immediate
  dead-letter; add/confirm a test that a Permanent classification dead-letters on the **first** drain (vs. the
  retry-cap path the finding-002 regression test covers).

### Honest boundary (documented)

SDK-**semantic** permanent rejections (a structurally-valid event the AAH SDK rejects, e.g. unknown tag) still
map to Retry→cap until the infra-gated `AahClientManagedAlarmEventWriter` surfaces richer per-event status — a
noted follow-up. This phase closes the **structurally-malformed (poison)** case the finding describes.

---

## Component C — AbCip nested-struct live-gate (backlog #6)

### Verdict (from the feasibility pass)

A **local** live-gate is architecturally impossible: `ab_server` (the libplctag CIP sim used by the default
`abserver` tier) does **not** implement the CIP Template Object service (class 0x6C) that nested-UDT discovery
depends on. The decode + threading already **shipped** (`3d8ce4e8`/`d203f31c`; `AbCipUdtMember.NestedTemplateId`
→ existing `@udt/{id}` fetch) and 301 offline tests pass. The honest close formalizes the gate at the existing
**Emulate** fidelity tier (Logix Emulate / real ControlLogix).

### Approach (skip-gated test + docs; NO runtime change)

1. **New `AbCipEmulateNestedUdtTests`** (mirror `AbCipEmulateUdtReadTests` + `AbServerProfileGate.SkipUnless(Emulate)`):
   drives `FocasDriver`-equivalent AbCip discovery against a nested-UDT-bearing Emulate project and asserts the
   nested struct's atomic leaves are addressable (`Parent.Status.Code`, `Parent.Status.Running`) + the nested
   sub-folder materializes. Skips cleanly on the default `abserver` tier (which can't serve Template Object).
2. **`docs/drivers/AbCip.md`** — document nested-struct support as **Emulate-tier verified** (ab_server lacks
   CIP Template Object), referencing the new test + the existing offline unit coverage.

### Testing

The Emulate test **compiles and skips** locally (no Logix Emulate on this Mac) — proving it is wired into the
suite. The decode/threading risk is already pinned by the shipped offline unit tests
(`CipTemplateObjectDecoderTests` + `AbCipDriverDiscoveryTests`).

---

## Component D — Reconcile + finish

- `stillpending.md` (never-staged): mark #3 (FOCAS wire command shipped + sim-proven), #5 (Wonderware
  poison-event structurally-malformed close + the SDK-semantic follow-up boundary), #6 (AbCip live-gate
  formalized as the Emulate skip-gated test).
- Update memory (`project_stillpending_backlog.md` + `MEMORY.md` index line).
- Build clean + targeted tests green (FOCAS + Wonderware + AbCip) + Component A live `/run` (focas-mock) +
  merge to master + push.

---

## Task slicing (independent → parallelizable)

| Task | Component | Project(s) | Class | Parallel with |
|---|---|---|---|---|
| T1 | FOCAS wire `cnc_getfigure` (client method + stub wire-in + mock handler) | Driver.FOCAS + focas-mock (py) | standard | T2, T3 |
| T2 | Wonderware per-event status (DTO ×2 + sidecar classifier + client consume + tests) | Wonderware{,.Client} + Core.AlarmHistorian.Tests | standard | T1, T3 |
| T3 | AbCip Emulate nested-UDT skip-gated test + AbCip.md | AbCip.IntegrationTests + docs | small | T1, T2 |
| T4 | FOCAS integration test (mock) + live `/run` verify | FOCAS.IntegrationTests | small | none (after T1) |
| T5 | Reconcile stillpending #3/#5/#6 + memory + finish (build, tests, merge+push) | docs (never-staged) | small | none |

Parallel implementers use **worktree isolation** (the shared-tree git-race lesson) since T1/T2/T3 touch disjoint
projects. T4 depends on T1; T5 runs last.

## Done =

Build clean + `dotnet test` green (Driver.FOCAS + Wonderware client/sink + AbCip) + Component A live `/run`
(focas-mock integration test executes & passes) + Components B/C offline-proven + merged to master + pushed.
