Files
lmxopcua/docs/plans/2026-08-07-adminui-followups-and-live-gates.md
T
Joseph Doherty dac7e1885d feat(resilience): route IDriver.InitializeAsync through the capability invoker (R2-10 T10 closed)
The connect path was the one capability call the resilience pipeline never
saw, so a driver that could not connect contributed zero breaker input and
the /hosts resilience panel read 'All targets healthy' against a dead
endpoint. InitializeAsync now routes through the invoker as
DriverCapability.Initialize, keyed on the driver-instance id.

- No pipeline retry: the actor's RetryConnect timer is the retry loop
  (parser forces retryCount overrides to 0, same funnel as write-shaped).
- Breaker threshold 2, not the Read-sized 5: the breaker samples 30 s and
  the reconnect timer fires every 10 s, so at most three attempts fit one
  window — threshold 5 could never open. Pinned by its own test.
- An open breaker fast-fails into the same InitializeFailed path as a real
  connect failure; the 15 s break window costs at most one extra tick.
  The best-effort teardown ShutdownAsync stays unwrapped.
- Companion fix: DriverCapabilityInvokerFactory.Create now invalidates the
  instance's tracker snapshots with its pipelines — a respawn during an
  outage discarded the only breaker whose OnClosed could clear the OPEN
  flag, stranding a false 'Breaker OPEN' chip forever.

Live gate PASSED on docker-dev (image symbol-verified per container):
rtu-gate against dead 10.100.0.35:5021 opened its Initialize breaker on
both centrals with the red 'BREAKER OPEN' chip on /hosts; a socat
5021→5020 forwarder recovered it (both breakers closed within 1 s, panel
back to 'All targets healthy'); healthy MAIN-modbus recorded zero breaker
events. Full suite 50/50 green. Evidence:
docs/plans/2026-08-07-adminui-followups-and-live-gates.md §7.
2026-08-08 05:45:08 -04:00

282 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-08-07 — adminui-followups closure + the four runnable live gates
**Scope.** The two items `deferment.md` §0.1 named as the remaining untracked engineering:
the unexecuted `2026-05-29-adminui-followups` plan, and the live gates that need only the
docker-dev rig (no hardware, no licence). There are exactly **four** such gate tasks:
archreview **R2-03 T11** and **T12**, **R2-10 T10**, and
`2026-06-14-driver-reconfigure-while-faulted` **Task 3**. The other open gates (Galaxy
`ScanStateProbeParityTests`, the v2 GA exit criteria) need a licence or external tooling and were
not attempted.
**Tree:** `master` @ `21009874`. **Rig:** docker-dev, image rebuilt for this run.
---
## 0. Rig provenance (done before anything was gated)
The rig was running an image built **2026-08-06 17:55**, three hours *older* than the last code
commit (`8b10d327`, 2026-08-06 21:09). Gating on it would have been meaningless. Rebuilt, both
centrals `--force-recreate`, then:
- **Image parity asserted per container** (`:9200` round-robins central-1/central-2, so a single
check proves nothing): both on `sha256:49d52d72…`.
- **Positive check that the code under test is in the artifact** — not merely that the image is
new: `RecordBreakerClose` + `IsBreakerOpen` present in the shipped `Core.dll`,
`DriverResilienceStatusPublisherService` in `OtOpcUa.Host.dll`, `EvaluationResult` +
`ClearCompiledScripts` in `Runtime.dll`; assemblies stamped 2026-08-07 09:29 UTC.
---
## 1. adminui-followups — CLOSED by audit, no code written
`deferment.md` called this "the largest unexecuted plan in the tree" and "the only substantial
engineering left" (19 pending / 0 completed). The plan's own first step is an overlap audit, and the
audit says **every task is already shipped, superseded by v3, or moot.** Per-task evidence is in
`docs/plans/2026-05-29-adminui-followups.md.tasks.json`; the summary:
| Group | Verdict |
|---|---|
| WS1 Tasks 1, 8 (`CollectionEditor<TRow>`, OpcUaClient endpoints) | **Shipped in the plan's own shape** — `CollectionEditor.razor`, consumed by `OpcUaClientDriverForm.razor` |
| WS1 Tasks 2–7 (per-driver tag/device editors ×6) | **Superseded by v3** — `TagConfigEditorMap` (11 editors) + `DeviceFormMap` (9 device forms) |
| WS2 Tasks 9, 10 (resilience typed form) | **Shipped**, deliberately narrower — see below |
| WS3 Tasks 11–16 (LDAP→role map) | **Shipped** end to end, incl. the login merge at `AuthEndpoints.cs:100` |
| WS4 Tasks 17, 18 (comment cleanup) | **Moot** — the files were deleted by v3 Batch 2 and #542 |
Two things worth carrying forward:
- **The plan's premise died with v3.** WS1 rests on "driver-embedded tag/device lists in
`DriverConfig` JSON are the runtime source of truth". In v3 tags are `Tag` rows authored in `/raw`
and the deploy artifact populates the driver's `RawTags`
(`ModbusDriverFactoryExtensions.cs:98-102`), so editing a list on a driver form no longer decides
what the driver polls. The capability was delivered, in a different shape.
- **WS2 shipped narrower than designed, correctly.** Two designed fields no longer exist:
`RecycleIntervalSeconds` went with the scheduled-recycle machinery (#522) and the bulkhead knobs
were deleted end-to-end by archreview R2-02 because `Build` never added a concurrency limiter.
Authoring either today would re-create an inert knob.
The supersession claims were checked for **reachability**, not just existence —
`TagConfigEditorMap.Resolve` is dispatched from `RawTagModal.razor:79` and
`RawManualTagEntryModal.razor:92`, `DeviceFormMap.Resolve` from `DeviceModal.razor:45`, both guarded
by `DriverFormMapParityTests` + `DriverDispatchMapParityTests`. A "superseded by X" whose X is itself
dead surface is the exact failure this repo has hit repeatedly.
---
## 2. R2-03 T11 — full-suite sweep ✅ PASSED
| Leg | Result |
|---|---|
| Solution build | **0 errors** |
| `Runtime.Tests` (whole project) | **555/555**, 3 skipped |
| `RoslynVirtualTagEvaluatorTests` | **20/20** |
The 3 skips are the deliberate `EquipmentDeviceBindingRetired` tombstones, matching the count
`deferment.md` §4.4 predicts. (The plan names the suite `RoslynVirtualTagEvaluatorTests`; it lives in
`Host.IntegrationTests`, not a project a solution-wide `--filter` reaches by default.)
---
## 3. R2-03 T12 — VT fail→Bad→recover ✅ PASSED, with one finding
Both blockers recorded against the previous attempt are **gone**: the Modbus fixture at
`10.100.0.35:5020` is now reachable from inside the rig (so driver data flows and a VT can be staged
Good), which is what made this gate runnable at all.
**Method.** The first two attempts produced a Bad result whose value was empty, but each followed a
deploy — and a deploy creates a fresh VT child with no last value, which is *correct by design* (the
inputs-ready gate that suppresses a cold-start flash, `VirtualTagActor.cs:246`). To isolate the
variable the trip was made **data-driven and deployment-free**: the script fails on a raw tag's
value, and that value was flipped by writing Modbus holding register 205 directly on the fixture, so
the VT child survives across the transition.
```
[09:51:57] GateVt = 42 (0x00000000) Good
[09:52:51] GateVt = (0x80020000) Bad_InternalError <- reg205 := 1, no deployment
[09:53:26] GateVt = 42 (0x00000000) Good <- reg205 := 0, no deployment
```
- ✅ **Degradation** — script failure degrades the node to `0x80020000 BadInternalError` instead of
freezing on the last Good. Server-side cause confirmed in the log:
`VirtualTag VT-gatevt: script execution threw / System.InvalidOperationException`.
- ✅ **Recovery** — returns to Good with the same value, exercising the dedup bypass.
- ✅ **P7 no-recompile spot-check** — two byte-identical redeploys (`revisionHash` identical across
both, `94f68785…`) caused **no disturbance at all**: `Ever went BAD: 0`, VT held Good=42.
Production has no compile log or metric, so the observable is the *consequence* of not
recompiling; the gate itself is pinned by `VirtualTagHostActorTests` (exactly two clears across
first-generation / identical-redeploy / edited-expression).
### ⚠️ Finding — "carrying the last-known value" never reaches the client
`VirtualTagActor`'s xmldoc (`:22`) and the R2-03 row in `archreview/plans/STATUS.md` both state the
Bad publish carries the last-known value. **A client never sees it.** Two independent channels agree:
- subscription: `GateVt = (0x80020000)` — value empty;
- raw OPC UA **Read** service (which returns a `DataValue` rather than throwing):
`StatusCode 0x80020000, Value <NULL>`.
The VT logic is **not** at fault. `OnEvaluationFailed` sends `_hasLastValue ? _lastValue : null`, and
a new regression test — `VirtualTagActorTests.The_Bad_result_carries_the_last_known_value` — proves
the actor emits `Bad` carrying `42`. The value is lost below the actor, in the OPC UA layer
(`OtOpcUaNodeManager.WriteValue` assigns `variable.Value` unconditionally, so the loss is at
Read/Publish encoding, where a Bad `StatusCode` conventionally carries no value).
**Why no test caught it:** every existing failure assertion in `VirtualTagActorTests` checks
`.StatusCode` alone (`:87, :101, :123, :142, :180`) — the "carrying the last-known value" half was
asserted **nowhere**. That test is now added.
Not filed as a defect because the client-visible behaviour may be the correct OPC UA reading; what is
certainly wrong is the **documentation**, which promises an operator something no client can observe.
### Confirmed still true: the Client.CLI `read` limitation
`read` surfaces a Bad status as a `ServiceResultException` from `FetchValueAsync` rather than
printing it — exactly as the previous pass recorded. `ReadCommand.cs:52` does print `Status:`, but
the SDK throws before reaching it. **Use `subscribe`** (prints value *and* status, tracks
`everBad`), or a raw Read.
---
## 4. R2-10 T10 — breaker-OPEN ❌ NOT ACHIEVABLE AS SPECIFIED
The gate asks to point a driver at a dead endpoint and watch the breaker open. **It cannot open that
way, for a structural reason** — not because of the rig. Three attempts, each failing at a different
point, together characterise it:
| Attempt | What happened |
|---|---|
| Spare Modbus driver → blackhole (`sql:1433`, accepts TCP, never answers Modbus) | Driver had **no pollable tags** (its only tag skipped: "tag config did not map to a definition") — no capability calls at all |
| `MAIN-modbus` (13 live tags) → same blackhole | Driver **connected**, reads failed (`MainPressure` → `0x80050000 BadTimeout`, row showed DEGRADED / "Modbus socket closed mid-response") — yet tracker stayed `ConsecutiveFailures = 0`, panel showed **"All targets healthy."** |
| `line3-sql` → unusable connection string (Sql `ReadAsync` *does* propagate) | Fails in **`InitializeAsync`** (liveness check) before ever reaching a wrapped Read |
**Root cause.** The breaker only sees failures that propagate as exceptions out of a
`CapabilityInvoker`-wrapped call. On the dead-endpoint path nothing does:
1. **`IDriver.InitializeAsync` is not wrapped.** `DriverInstanceActor.cs:707` calls it directly; the
invoker wraps only Read / Write / Subscribe / Discover / AlarmAcknowledge (`:774, :827, :870,
:899, :1081`). A driver that cannot connect therefore contributes **zero** breaker input — which
directly refutes the gate's premise.
2. **Modbus swallows per-tag failures, in both directions.** `ReadAsync` converts them to
`Degraded` health (`:365-375, :838-855`) and `WriteAsync` converts them to a `WriteResult` status
code — neither throws. So even the fallback trigger the plan suggests ("a rapid idempotent
write-to-dead-endpoint") cannot work for Modbus.
The breaker itself is configured to open on **2 failures in a 30 s window** at ratio 1.0
(`DriverResiliencePipelineBuilder.cs:168-175`), so the previously-recorded explanation — that
R2-09's ConnectionBackoff throttles retries below the threshold — is not the whole story and is
arguably not the operative one: the failures never enter the pipeline at all.
**Operator consequence worth recording:** for the scenario operators care about most — a driver that
cannot reach its device — the resilience panel is **permanently quiet**. It showed *"All targets
healthy."* for a driver that was DEGRADED, timing out every read against a blackhole.
**What WAS verified live:** the panel renders on `/hosts` (rehomed there by #546 — the plan's "on the
driver page" step is stale, v3 Batch 2 retired those pages), the expanded row shows the RESILIENCE
block, and `DriverResilienceStatusPublisherService` is publishing real tracker state every 5 s
(`BreakerOpen`, `ConsecutiveFailures`, `CurrentInFlight`, `LastBreakerOpenUtc` all observed on the
wire). The reader/store/panel chain is live; only the breaker-open **state** remains unforced.
> Note: `DriverResilienceStatusChanged` going to `$DeadLetterWithNoSubscribers` on the node is
> **expected**, not a defect — the rig runs Phase-5 telemetry in `Grpc` mode, where the node always
> double-publishes and central consumes via the gRPC dialer, leaving the DPS copy unsubscribed.
**To actually close T10** someone must either wrap the connect path, stop drivers swallowing
capability failures, or pick a driver whose *wrapped* call fails after a successful init (Sql's
`ReadAsync` propagates — it needs a backend that dies *after* initialization, e.g. `docker pause` on
a dedicated fixture). Left OPEN with this root cause recorded.
---
## 5. driver-reconfigure-while-faulted Task 3 ✅ PASSED
The task was deferred in 2026-06 with "skip the live test part"; its block ("user-driven sign-in")
no longer holds because docker-dev runs the AdminUI with `DisableLogin`.
```
before central-1 startedAt=2026-08-07T09:50:29.939876456Z restarts=0
10:16:18 DriverInstance DRV-ce2fb5217f5d: initialize failed: Connection refused <- bad config (:5999)
10:22:13 DriverInstance DRV-ce2fb5217f5d: connected <- corrected config (:5020)
10:22:13 DriverInstance DRV-ce2fb5217f5d: subscribed to 1 refs
after central-1 startedAt=2026-08-07T09:50:29.939876456Z restarts=0
```
The driver **adopted the corrected config and connected while faulted**, and `startedAt` is
byte-identical before and after with `restarts=0` — **no node restart**, which is the whole point
(pre-fix it would retry the stale config forever).
---
## 6. Rig state
Restored to as-found: Modbus device back on `10.100.0.35:5020`, `line3-sql` back on `DevSql`,
all `ResilienceConfig` overrides cleared, the `SC-gatevt` script restored to its original body.
Post-restore verification: `MainPressure = 7 (Good)`, `GateVt = 7 (Good)` — the VT is back on its
original one-line body, so it mirrors `MainPressure` again rather than the `×6` used during the gate.
One anomaly recorded rather than explained: both centrals were **recreated** at 09:49:40/09:49:50
(new `Created`, `RestartCount=0`) by something this session did not issue. It postdates the
observations above and the old container's logs went with it, so it is noted, not attributed.
---
## 7. Addendum 2026-08-08 — R2-10 T10 ✅ PASSED after fixing the structural gap
§4 concluded the gate was not achievable as specified because the connect path never entered the
resilience pipeline. The user chose to fix that ("do option 1"), and the gate then passed against
the fixed build. Branch `feat/wrap-driver-init-in-resilience`.
### The fix
- **`DriverCapability.Initialize`** — `IDriver.InitializeAsync` routes through the capability
invoker in `DriverInstanceActor.InitializeAsync` (both the initial connect and the
`Reconnecting` retry path; the best-effort teardown `ShutdownAsync` stays deliberately
unwrapped). Keyed on the driver-instance id, like a bulk subscribe.
- **No pipeline retry for Initialize** — the actor's `RetryConnect` timer is the retry loop.
Enforced at the parser funnel (`retryCount` override forced to 0, same shape as the
write-shaped rule) and in every tier default.
- **Breaker threshold 2, not the Read-sized 5** — the breaker samples 30 s and the reconnect
timer fires every 10 s, so at most three init attempts fit one window; threshold 5 could never
open. Pinned by `Initialize_BreakerThreshold_IsReachableAtTheReconnectCadence` +
`Initialize_BreakerOpens_AtTierDefaults_AndFastFailsTheNextAttempt`.
- **Companion fix — respawn resets tracker snapshots.** `DriverCapabilityInvokerFactory.Create`
now calls `DriverResilienceStatusTracker.Invalidate(driverInstanceId)` alongside the pipeline
invalidation. Without it, a deploy during an outage discards the only breaker whose `OnClosed`
could clear the OPEN flag, stranding a false "Breaker OPEN" chip forever. Unreachable before
this change made breaker-open reachable; found by asking what clears the flag, not by a test.
- Wiring pinned by `Initialize_routes_through_the_capability_invoker` +
`An_invoker_fast_fail_on_init_keeps_the_actor_retrying_until_recovery` (falsification-controlled:
both go red with the actor wrap stashed; `Create_resets_the_instances_tracker_state_with_its_pipelines`
goes red with the factory call stashed).
### The gate run (docker-dev, image `df04c76ce06b`, symbol-verified on both centrals)
The staged failure was free: `rtu-gate` (`DRV-3616122eb53d`) was already authored against
`10.100.0.35:5021`, where nothing listens — connection refused on every init.
1. **Breaker OPEN, both replicas** — within ~20 s of boot, both centrals logged
`circuit-breaker OPENED: instance=DRV-3616122eb53d … capability=Initialize … Connection refused`,
re-opening each half-open probe cycle. Bonus coverage: `DRV-opcua1` (invalid config → init
throws) also tripped — a permanently-misconfigured driver was equally invisible before.
2. **Panel chip** — `/hosts` → rtu-gate → Controls showed the red **"BREAKER OPEN · OPENED 19S
AGO"** chip in the RESILIENCE section, row FAULTED with "Connection refused".
3. **Control** — healthy `MAIN-modbus` recorded zero breaker events throughout.
4. **Recovery clear** — a socat forwarder (`5021 → 5020` on the docker host) brought the endpoint
alive; both centrals logged `circuit-breaker closed (recovered)` + `reconnected` within 1 s of
each other, and the panel dropped to "All targets healthy."
5. **Teardown** — socat removed; 5021 dead again (as-found). rtu-gate returns to a truthfully-OPEN
breaker chip, which is the feature working.
### Two nuances against the original T10 wording
- **"Both-replica chips"** resolves as both nodes' *trackers* tripping (proven in both logs)
rendered through the single **active** AdminUI. Traefik's `/health/active` check deliberately
routes the UI to the active admin node only — the standby returns 503
(`Standby: active node for role 'admin' is …`), so there is never a second replica serving
chips. Discovered while trying to force the sticky-cookie LB onto central-1.
- **The staleness-dim leg was not exercised.** Both pair nodes publish under the same
`(instance, host)` key, so staleness requires *every* publisher for a key to stop — deliberate
node-kill churn this gate did not do. The dim is a pure render predicate (`IsStale` → opacity).
Also corrected while here: §4's "ConnectionBackoff throttles retries" explanation (the previously
recorded root cause) is confirmed wrong — the breaker config opens on 2 failures in 30 s; the
operative cause was always that no failure reached the pipeline at all.