diff --git a/docs/plans/2026-06-16-stillpending-phase-5-probes.md b/docs/plans/2026-06-16-stillpending-phase-5-probes.md new file mode 100644 index 00000000..3d7eaea4 --- /dev/null +++ b/docs/plans/2026-06-16-stillpending-phase-5-probes.md @@ -0,0 +1,295 @@ +# Phase 5 — Test-Connect Protocol Handshakes — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task. + +**Goal:** Replace the 8 bare-TCP Test-Connect probes with real protocol handshakes so a live-but-rejecting device reads RED, not green — reusing each driver's own client primitive, with graceful degradation for the three (TwinCAT/FOCAS/Galaxy) that can't run a real handshake on the dev rig. + +**Architecture:** Per-probe, no shared scaffold (matches the existing self-contained probe style; keeps all 8 driver projects disjoint → parallelizable). Each probe keeps its TCP preflight and adds one handshake step. New three-way result contract (TCP-fail / handshake-ok / TCP-ok-but-handshake-rejected / timeout). `IDriverProbe`/`DriverProbeResult` and DI are UNCHANGED. Design: `docs/plans/2026-06-16-stillpending-phase-5-probes-design.md`. + +**Tech Stack:** C# / .NET 10, xUnit + Shouldly, in-process `TcpListener` for unit tests, skip-gated `DriverTestConnectE2eTests` for live verification. Per-driver client libs already referenced (S7netplus, libplctag, Beckhoff.TwinCAT.Ads, OPCFoundation.Opc.Ua.Client, MxGateway.Client gRPC, FOCAS wire P/Invoke). + +**Consistent result-message templates (apply in EVERY probe):** +- TCP connect fails → `Ok=false`, `"Connect failed: {SocketErrorCode}"` *(keep as-is)* +- Handshake OK → `Ok=true`, `Latency`, e.g. `"Modbus FC03 OK"`, `"OPC UA: {n} endpoint(s)"`, `"S7 connected (CPU {cpu})"`, `"CIP session OK"`, `"ADS state: {state}"`, `"gateway gRPC OK"` +- TCP OK but handshake rejected → `Ok=false`, `"Reachable at {host}:{port} but {proto} handshake failed: {detail}"` +- Timeout (`OperationCanceledException`) → `Ok=false`, `"Probe timed out after {timeout.TotalSeconds:F0}s."` *(keep as-is)* +- Degrade (TwinCAT/FOCAS only, env can't run handshake) → `Ok=true`, `"Reachable at {host}:{port} ({proto} handshake unavailable on this host — TCP reachability only)"` + +**Global rules (every task):** TDD red→green. Probes MUST honour `ct` and MUST NOT mutate state. 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 the gateway API key. No `--no-verify`, no force-push. No `IDriverProbe`/`DriverProbeResult`/DI change. No bUnit. + +--- + +### Task 0: Feature branch *(done)* + +Branch `feat/stillpending-phase-5-probes` off master `050f5c4b` already created; design committed `1f2d32ac`. No action. + +--- + +### Task 1: Modbus handshake — FC03 read + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Tasks 2, 3, 4, 5, 6, 7, 8 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverProbeTests.cs` (create) + +**Approach:** After the existing TCP preflight succeeds, do the real handshake with the in-project transport: +```csharp +// proto label for messages +const string Proto = "Modbus"; +// ... existing deserialize + ExtractTarget + TCP preflight unchanged ... +// On TCP success, run a one-shot FC03 (Read Holding Registers, qty 1 @ addr 0): +await using var transport = new ModbusTcpTransport(host, port, /* keep-alive */ default, /* timeouts from opts/defaults */); +await transport.ConnectAsync(ct); +var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 }; // FC03, addr 0, qty 1 +try +{ + _ = await transport.SendAsync(opts.UnitId /* or default 1 */, pdu, ct); + return new(true, "Modbus FC03 OK", sw.Elapsed); +} +catch (ModbusException) // exception PDU (e.g. illegal data address) STILL proves a real Modbus device +{ + return new(true, "Modbus FC03 OK (device returned exception PDU)", sw.Elapsed); +} +``` +- Inspect `ModbusTcpTransport`'s real ctor signature (`ModbusTcpTransport.cs:27-66`) and `ModbusDriverOptions` for the unit-id field; mirror how `ModbusDriver` constructs the transport. Keep the `SocketException`/`OperationCanceledException`/`Exception` catches; a non-`ModbusException` failure after TCP success → `Ok=false`, `"Reachable at {host}:{port} but Modbus FC03 handshake failed: {ex.Message}"`. +- Update the class XML-doc: it now performs a real FC03 handshake (drop the "Does NOT exchange any protocol bytes" sentence). + +**Steps:** (1) Write failing tests. (2) Run → fail. (3) Implement handshake. (4) Run → pass. (5) `dotnet build` the Modbus project clean. (6) Commit. + +**Tests (`ModbusDriverProbeTests`, in-process `TcpListener`):** +- `ProbeAsync_invalid_json → Ok=false` ("invalid"). +- `ProbeAsync_no_host → Ok=false` ("no host/port"). +- `ProbeAsync_unreachable_port → Ok=false` (Connect failed) — target a closed loopback port. +- `ProbeAsync_tcp_accepts_then_closes → Ok=false` with "handshake failed" — a `TcpListener` that accepts and immediately closes (no MBAP reply). +- `ProbeAsync_canned_MBAP_response → Ok=true` "Modbus FC03 OK" — a `TcpListener` that reads the request frame and writes a valid MBAP FC03 response echoing the TxId. +- (optional) `ProbeAsync_exception_PDU → Ok=true` — listener replies 0x83 + exception code. + +**Commit:** `feat(probe): Modbus Test-Connect does a real FC03 handshake` + +--- + +### Task 2: OpcUaClient handshake — GetEndpoints + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Tasks 1, 3, 4, 5, 6, 7, 8 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverProbeTests.cs` (create) + +**Approach:** After TCP preflight, do an unsecured discovery handshake (no session, no app-cert, no auth) — mirror `OpcUaClientDriver.cs:417-424`: +```csharp +using var client = await DiscoveryClient.CreateAsync(new Uri(endpointUrl) /* + the SDK's default config as the driver does */); +var endpoints = await client.GetEndpointsAsync(null, ct); +return endpoints is { Count: > 0 } + ? new(true, $"OPC UA: {endpoints.Count} endpoint(s)", sw.Elapsed) + : new(false, $"Reachable at {host}:{port} but OPC UA handshake failed: server published 0 endpoints", null); +``` +- Reuse the EXACT `DiscoveryClient.CreateAsync(...)` overload the driver uses (Read `OpcUaClientDriver.cs:405-424` for the arg shape — it may pass an `ApplicationConfiguration`/`EndpointConfiguration`). Honour `ct`. A non-OPC-UA TCP server makes `GetEndpointsAsync` throw/timeout → catch → `Ok=false` "handshake failed: {ex.Message}". Keep the timeout/Connect-failed catches. +- Update the class XML-doc (drop "Does NOT open an OPC UA session" → now does a GetEndpoints discovery handshake). + +**Tests:** invalid-json / no-endpoint / unreachable / tcp-accepts-then-closes→handshake-fail. The happy path (real endpoints) is covered live in Task 11 (a faithful in-process OPC UA server is heavy; the accept-then-close negative path is the unit-testable new behavior). + +**Commit:** `feat(probe): OpcUaClient Test-Connect does a GetEndpoints discovery handshake` + +--- + +### Task 3: S7 handshake — Plc.OpenAsync + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Tasks 1, 2, 4, 5, 6, 7, 8 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverProbeTests.cs` (create) + +**Approach:** After TCP preflight, do the COTP+S7 setup handshake — mirror `S7Driver.cs:162-171`: +```csharp +var plc = new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot); +plc.ReadTimeout = (int)timeout.TotalMilliseconds; // set BEFORE OpenAsync (handshake honours it) +try +{ + await plc.OpenAsync(ct); + if (plc.IsConnected) return new(true, $"S7 connected (CPU {opts.CpuType})", sw.Elapsed); + return new(false, $"Reachable at {host}:{port} but S7 handshake failed: not connected", null); +} +finally { plc.Close(); } +``` +- Reuse `S7CpuTypeMap.ToS7Net` (`S7CpuTypeMap.cs`). Read `S7DriverOptions` for Rack/Slot/CpuType field names. Wrong rack/slot or non-S7 server → `OpenAsync` throws → catch → `Ok=false` "handshake failed: {ex.Message}". Keep Connect-failed / timeout catches. +- Update the class XML-doc. + +**Tests:** invalid-json / no-host / unreachable / tcp-accepts-then-closes→handshake-fail (a listener that accepts then closes makes `OpenAsync` throw). Happy path is live (Task 11, python-snap7 sim). + +**Commit:** `feat(probe): S7 Test-Connect does a real ISO-on-TCP + S7 setup handshake` + +--- + +### Task 4: AbCip handshake — libplctag init + +**Classification:** small +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 1, 2, 3, 5, 6, 7, 8 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs` (create) + +**Approach:** After TCP preflight, open a CIP session via `libplctag` by initializing one Tag against the first device: +- Build an `AbCipTagCreateParams` from the first device's options (Gateway + CIP path + PlcType/libplctag-attr + a tag name) and `new LibplctagTagRuntime(p).InitializeAsync(ct)`. Read `LibplctagTagRuntime.cs` + `AbCipTagCreateParams` + how `AbCipDriver` builds these (`AbCipDriver.cs` device-init path, ~`:824`/`:856`) for the exact param shape and where the family/PlcType comes from. +- For the tag name: prefer the first configured tag path if `opts` carries tags; else a benign placeholder. Interpret the outcome: + - `InitializeAsync` succeeds → `Ok` `"CIP session OK"`. + - A **CIP-level** error (tag-not-found / bad-path — inspect `GetStatus()` / the libplctag `Status` enum) → STILL `Ok` `"CIP session OK (controller reachable; probe tag not found)"` — the controller answered CIP. + - A session/ForwardOpen/connect/timeout error → `Ok=false` `"Reachable at {host}:{port} but CIP handshake failed: {detail}"`. +- Dispose the runtime/tag. Update the class XML-doc. + +**Tests:** invalid-json / no-host / unreachable. The CIP-status interpretation happy/CIP-error paths are covered live (Task 11, CIP sim). Keep unit tests to the offline-determinable paths; do NOT spin a fake CIP server. + +**Commit:** `feat(probe): AbCip Test-Connect opens a real CIP session (libplctag init)` + +--- + +### Task 5: AbLegacy handshake — libplctag init (PCCC) + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Tasks 1, 2, 3, 4, 6, 7, 8 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs` (create) + +**Approach:** Same libplctag-init pattern as Task 4 but the AbLegacy project's own runtime/params types (PCCC protocol family). Read the AbLegacy driver's device-init/tag-runtime code for the exact param shape (it mirrors AbCip). Same outcome interpretation (session-open or CIP/PCCC-level error → `Ok`; connect/timeout → handshake-fail). Message: `"PCCC session OK"` / `"Reachable … but PCCC handshake failed: {detail}"`. Update the class XML-doc. + +**Tests:** invalid-json / no-host / unreachable. Happy path is **deferred** (no PLC5/SLC sim on the rig) — note this in the test file header; the handshake code path is the same library as AbCip (verified-by-proxy). + +**Commit:** `feat(probe): AbLegacy Test-Connect opens a real PCCC session (libplctag init)` + +--- + +### Task 6: TwinCAT handshake — ADS ReadState (degrade-guarded) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 1, 2, 3, 4, 5, 7, 8 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverProbeTests.cs` (create) + +**Approach:** After TCP preflight, attempt an ADS state read — mirror `AdsTwinCATClient.cs:87-90,194`: +```csharp +using var client = new AdsClient(); +client.Connect(parsed.NetId, parsed.Port); // AmsNetId + ADS port from the parsed address +var state = await client.ReadStateAsync(ct); // AdsState +return new(true, $"ADS state: {state.AdsState}", sw.Elapsed); +``` +- **Degrade guard:** wrap construction/connect in try/catch. Distinguish: + - ADS connected + `ReadState` OK → `Ok` `"ADS state: {state}"`. + - ADS **route/auth rejection** from a reachable router (the AdsErrorCode indicates target-port/route) → `Ok=false` `"Reachable at {host}:{port} but ADS handshake failed: {AdsErrorCode} — check the target's ADS route table authorizes this host"` (a true RED — the driver also needs the route). + - The managed AMS router can't construct/run headless (any other exception that means the handshake could not be ATTEMPTED, not that the device rejected it) → **degrade**: `Ok=true` `"Reachable at {host}:{port} (ADS handshake unavailable on this host — TCP reachability only)"`. +- Use the existing `TwinCATAmsAddress.TryParse` for NetId+port (already in `ExtractTarget`). Honour `ct`/timeout. Read the `Beckhoff.TwinCAT.Ads` `AdsClient` API (`Connect`, `ReadStateAsync`, `AdsErrorException`/`AdsErrorCode`) to classify route-rejection vs construction-failure. Update the class XML-doc. + +**Tests:** invalid-json / no-host / unreachable (black-hole → timeout or degrade). Assert the degrade path returns `Ok=true` with the "TCP reachability only" note when `AdsClient` cannot attempt the handshake. Happy/route-reject paths are **deferred** (no ADS target on the rig) — note in the test header. + +**Commit:** `feat(probe): TwinCAT Test-Connect does an ADS ReadState (degrade-guarded)` + +--- + +### Task 7: FOCAS handshake — cnc_allclibhndl3 (degrade-guarded) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 8 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDriverProbeTests.cs` (create) + +**Approach:** After TCP preflight, attempt the FOCAS library handshake via the existing wire P/Invoke (`Wire.WireFocasClient` / `FocasWireClient` — read those for the `cnc_allclibhndl3`/`cnc_freelibhndl` entry points). Build the wire client directly (do NOT route through `UnimplementedFocasClientFactory`, which throws by design). Allocate a handle to the first device's host/port and free it. +- **Degrade guard:** the FWLIB native lib is absent on the dev box / Linux containers → the P/Invoke throws `DllNotFoundException` / `NotSupportedException` / `TypeInitializationException`. Catch those specifically and **degrade**: `Ok=true` `"Reachable at {host}:{port} (FOCAS handshake unavailable on this host — FWLIB absent, TCP reachability only)"`. + - Handle allocated OK → `Ok` `"FOCAS handle OK"`. + - FWLIB present but `cnc_allclibhndl3` returns an error code (e.g. EW_SOCKET) from a reachable-but-non-CNC host → `Ok=false` `"Reachable at {host}:{port} but FOCAS handshake failed: {focasRc}"`. +- Honour `ct`/timeout (FWLIB connect can block — run it on a worker/`Task.Run` bounded by the linked timeout CTS so the probe still returns within budget). Update the class XML-doc. + +**Tests:** invalid-json / no-host / unreachable. **Assert the degrade path** — on the CI/dev box (no FWLIB) the probe against a reachable TCP listener returns `Ok=true` with the "FWLIB absent" note (this IS the dev-box behavior, so it's directly testable). Happy/CNC-error paths are **deferred** (no CNC + no FWLIB) — note in the test header. + +**Commit:** `feat(probe): FOCAS Test-Connect attempts a cnc_allclibhndl3 handshake (degrade-guarded)` + +--- + +### Task 8: Galaxy handshake — gRPC ping (auth-rejection = reachable) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 1, 2, 3, 4, 5, 6, 7 + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs` +- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverProbeTests.cs` (create) + +**Approach:** After TCP preflight, build a `Grpc.Net.Client` channel to `Gateway.Endpoint` honouring the cleartext/TLS setting (read how `GalaxyDriver` / `GatewayGalaxy*` construct the channel — there's an http2-cleartext path for the dev gw), and issue ONE lightweight unary call from `MxGateway.Client`/`MxGateway.Contracts` (pick the cheapest — e.g. a status/echo/health, else the smallest query). **Do NOT resolve `secretref:` secrets** — send whatever key string is in the transient config. +- Interpret the gRPC `StatusCode`: + - `OK` → `Ok` `"gateway gRPC OK"`. + - `Unauthenticated` / `PermissionDenied` → **also `Ok`** `"gateway reachable & speaking gRPC (auth not checked)"` — proves a live mxgw server. + - `Unavailable` / transport error / deadline → `Ok=false` `"Reachable at {host}:{port} but gateway gRPC handshake failed: {StatusCode}"`. +- Honour `ct`/timeout (set the gRPC deadline from `timeout`). Dispose the channel. Update the class XML-doc. + +**Tests:** invalid-json / no-endpoint / unreachable (black-hole → `Unavailable`/deadline → `Ok=false`). The `Unauthenticated⇒Ok` rule: if a tiny in-process gRPC server is disproportionate, cover it live (Task 11, gateway `10.100.0.48:5120`) and unit-test the StatusCode→result classification by factoring the mapping into a small pure helper (e.g. `static (bool ok, string msg) ClassifyRpc(StatusCode, host, port)`) and testing that directly. + +**Commit:** `feat(probe): Galaxy Test-Connect does a gRPC ping (auth-rejection counts as reachable)` + +--- + +### Task 9: Docs + bookkeeping + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** none (blocked by 1–8) + +**Files:** +- Modify: `docs/plans/2026-05-28-adminui-driver-pages-design.md` (mark Phase 7 real-probes done) — OR add a "Test-Connect probes" section to the most appropriate driver doc. +- Modify: `docs/Historian.md` or a probes note — record that the Historian probe was already a real handshake. +- Create/Modify: a short `docs/drivers/TestConnectProbes.md` (or a section in an existing driver overview) documenting the per-driver handshake + the three degrade behaviors + the consistent message contract. + +**Steps:** Document the 8 handshakes, the degrade semantics (TwinCAT route table; FOCAS FWLIB-absent), and the auth-rejection=reachable Galaxy rule. Note AbLegacy/TwinCAT/FOCAS live-verify deferred (no sim/target/FWLIB). Commit: `docs(phase5): real Test-Connect handshakes per driver + degrade semantics`. + +--- + +### Task 10: Full build + test + final integration review + +**Classification:** high-risk (final integration gate — degrade guards + no-regression-vs-TCP across 8 disjoint probes) +**Estimated implement time:** ~6 min +**Parallelizable with:** none (blocked by 1–9) + +**Steps:** +1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors (production projects are `TreatWarningsAsErrors`). +2. `dotnet test` for the 8 driver `.Tests` projects → all green. +3. Final integration review focus: (a) every probe still returns the *unchanged* `"Connect failed"` / `"timed out"` messages on those paths (no regression for offline devices); (b) the TwinCAT + FOCAS **degrade guards** truly catch "cannot-attempt" vs "device-rejected" and never emit a worse-than-TCP result; (c) Galaxy's `Unauthenticated⇒Ok`; (d) no probe mutates state; (e) no `IDriverProbe`/`DriverProbeResult`/DI change leaked. +4. Commit any review fixes. + +--- + +### Task 11: Live `/run` — extend E2E + run the 5 verifiable probes + +**Classification:** high-risk (acceptance gate, agent-driven) +**Estimated implement time:** ~8 min +**Parallelizable with:** none (blocked by 10) + +**Files:** +- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverTestConnectE2eTests.cs` (add OpcUaClient/S7/AbCip/Galaxy happy + wrong-port cases, skip-gated like the Modbus ones). + +**Steps:** +1. Extend `DriverTestConnectE2eTests` with skip-gated happy-path + wrong-port cases for OpcUaClient (`opc.tcp://10.100.0.35:50000`), S7 (`10.100.0.35:1102`), AbCip (`10.100.0.35:44818`), Galaxy (`10.100.0.48:5120`), mirroring the existing Modbus pattern (`DockerFixtureAvailability.IsReachable` skip). +2. Run the integration suite from the dev Mac (sims reachable): assert each verifiable probe is GREEN vs its live sim and RED vs a wrong port. For Galaxy, source the key WITHOUT echoing if a call needs it (per the established recipe) — but the probe should report reachable even without it. +3. Record results. AbLegacy / TwinCAT / FOCAS happy-path live-verify is **honestly deferred** (no PLC5/SLC sim, no ADS target, no CNC+FWLIB) — unit-proven + degrade-guarded. +4. Commit the E2E additions: `test(phase5): live Test-Connect E2E for OpcUaClient/S7/AbCip/Galaxy (skip-gated)`. + +--- + +## Done = +Build clean + all driver `.Tests` green + final integration review SHIP + the 5 verifiable probes live-proven GREEN/RED on the rig + docs updated. Then `finishing-a-development-branch` → merge to master + push. diff --git a/docs/plans/2026-06-16-stillpending-phase-5-probes.md.tasks.json b/docs/plans/2026-06-16-stillpending-phase-5-probes.md.tasks.json new file mode 100644 index 00000000..20bdcce0 --- /dev/null +++ b/docs/plans/2026-06-16-stillpending-phase-5-probes.md.tasks.json @@ -0,0 +1,18 @@ +{ + "planPath": "docs/plans/2026-06-16-stillpending-phase-5-probes.md", + "branch": "feat/stillpending-phase-5-probes", + "tasks": [ + {"id": 441, "subject": "P5 Task 1: Modbus probe — real FC03 handshake", "status": "pending"}, + {"id": 442, "subject": "P5 Task 2: OpcUaClient probe — GetEndpoints discovery handshake", "status": "pending"}, + {"id": 443, "subject": "P5 Task 3: S7 probe — Plc.OpenAsync (COTP+S7 setup) handshake", "status": "pending"}, + {"id": 444, "subject": "P5 Task 4: AbCip probe — libplctag CIP session handshake", "status": "pending"}, + {"id": 445, "subject": "P5 Task 5: AbLegacy probe — libplctag PCCC session handshake", "status": "pending"}, + {"id": 446, "subject": "P5 Task 6: TwinCAT probe — ADS ReadState (degrade-guarded)", "status": "pending"}, + {"id": 447, "subject": "P5 Task 7: FOCAS probe — cnc_allclibhndl3 (degrade-guarded)", "status": "pending"}, + {"id": 448, "subject": "P5 Task 8: Galaxy probe — gRPC ping (auth-rejection = reachable)", "status": "pending"}, + {"id": 449, "subject": "P5 Task 9: Docs + bookkeeping", "status": "pending", "blockedBy": [441, 442, 443, 444, 445, 446, 447, 448]}, + {"id": 450, "subject": "P5 Task 10: Full build + test + final integration review", "status": "pending", "blockedBy": [441, 442, 443, 444, 445, 446, 447, 448, 449]}, + {"id": 451, "subject": "P5 Task 11: Live /run — extend E2E + run 5 verifiable probes", "status": "pending", "blockedBy": [450]} + ], + "lastUpdated": "2026-06-16" +}