# Plan 07 — Client Tooling, Analyzers & Cross-Cutting Engineering System

> **Status (2026-07-08):** ✅ **C-1 DONE** — `fix/archreview-c1-wire-analyzer` `f0082af5` (OTOPCUA0001 wired
> tree-wide + ~280 hits triaged; negative-control-verified live; **surfaced the RESILIENCE-DISPATCH-GAP, task
> #10**). ✅ **S-1/S-2/S-4 DONE** — `fix/archreview-s1-s2-ci-coverage` `10b89830` (whole-solution unit leg +
> `scripts/ci/assert-not-all-skipped.sh` fail-on-skip gate + CLI sleep→readiness deflake; skip-gate proven
> locally, CI-run verification pending a push). Remaining in this domain: S-3 (nightly E2E no-op), S-7
> (global.json), U-3/U-4/U-5/U-6 hygiene, C-2..C-6. See [`STATUS.md`](STATUS.md).

| | |
|---|---|
| **Source report** | `archreview/07-client-tooling-engineering.md` |
| **Overall context** | `archreview/00-OVERALL.md` (theme #4 "verification system", action #6/#11) |
| **Base commit** | `9cad9ed0` (master); verified against current working tree 2026-07-08 |
| **Domain** | Client.CLI/Shared/UI, Analyzers, build/CPM, CI, test architecture, repo hygiene |

## Verification summary

26 findings in the report (S-1..S-8, P-1..P-4, C-1..C-6, U-1..U-8). Every actionable
finding was re-verified against the live tree; **all confirmed, none stale**. Four items are
explicitly "no-action / positive" in the report (P-3, P-4, C-3, U-1) and are recorded but not
planned. Key verifications performed:

- **C-1 CONFIRMED** — repo-wide grep for `ZB.MOM.WW.OtOpcUa.Analyzers` in `.csproj/.props/.targets`
  returns only the analyzer's own csproj and its test project's `ProjectReference`. Zero
  `OutputItemType="Analyzer"` anywhere in the tree (the only hit is inside the review markdown).
- **S-1/S-2 CONFIRMED** — `v2-ci.yml:47-52` gates exactly 5 unit projects; slnx contains 47
  `*.Tests.csproj` (10 of them `*.IntegrationTests`). Integration job (`:61-76`) runs
  fixture-less on `ubuntu-latest` with no `services:`. Category traits are declared via
  `[Trait("Category", "…")]`, so a `Category!=E2E&Category!=LiveIntegration` filter is valid.
- **S-3 CONFIRMED** — grep for `Category…E2E` across `tests/**/*.cs` returns zero matches; the
  nightly workflow's own header admits the no-op.
- **S-7 CONFIRMED** — no `global.json` at root (nor anywhere); `v2-ci.yml:10-12` claims one exists.
- **U-3 CONFIRMED** — `docs/Client.CLI.md` has zero sections for `confirm`, `shelve`, `disable`
  (and only substring hits for `ack`/`enable`); all 5 alarm commands ship as command files.
- **U-4 CONFIRMED** — `git ls-files` shows `pending.md`, `current.md`, `looseends.md`,
  `stillpending.md`, `HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md` all tracked; `pending.md:3` itself
  declares "HARD RULE: never stage `pending.md` / `current.md`".
- **U-5 CONFIRMED** — `git ls-files lib/` lists 7 tracked proprietary AVEVA DLLs; zero `HintPath`
  hits reference `lib/` anywhere.
- **U-6 CONFIRMED** — `sql_login.txt` is `.gitignore:47`, `git check-ignore` matches, `git log`
  empty (never committed). The retired `Driver.Historian.Wonderware*` dirs under `src/`+`tests/`
  are untracked `bin/obj` husks (NOT tracked projects; not in slnx). Tracked Wonderware residue is
  only `docs/drivers/Historian.Wonderware.md` (an intentional retired-stub) and 3
  `code-reviews/Driver.Historian.Wonderware*/findings.md`.
- **C-2 CONFIRMED** — none of the 4 client/tooling src csprojs set `TreatWarningsAsErrors`.
- **C-5 CONFIRMED** — no `.editorconfig`; `StyleGuide.md:3` says "for all **ScadaBridge**
  documentation".
- **S-5/S-6/U-8 CONFIRMED** — `OpcUaClientService.cs` lock gaps at cited lines; `CommandBase.cs:123`
  `Log.CloseAndFlush()` + `:133` global `Log.Logger` swap; `CommandBase.cs:91`
  `AutoAcceptCertificates = true`.

---

## Priority ordering

**Tier A — restore meaning to green CI (do first):** C-1, S-1, S-2, S-3, S-7.
**Tier B — repo hygiene:** U-5, U-4, U-6, C-5 (StyleGuide title).
**Tier C — docs:** U-3.
**Tier D — engineering debt (as capacity allows):** C-2, P-1, U-2, S-5, S-6, S-4, C-4, C-6, S-8, U-8, P-2.
No-action (recorded only): P-3, P-4, C-3, U-1, U-7.

---

## TIER A — Restore meaning to green CI

### C-1 (High) — Wire the OTOPCUA0001 analyzer into every project

**Restatement:** the sole custom analyzer enforces its CapabilityInvoker-wrapping rule against
nothing but its own 31 unit tests; no consuming project references it.

**Verification:** confirmed — only self + test `ProjectReference`; zero `OutputItemType="Analyzer"`.

**Root cause:** the "built-but-never-wired" house failure mode (theme #1). The analyzer project was
authored and unit-tested but the DI/build-graph wiring step was never done.

**Design:** inject the analyzer as an analyzer-type `ProjectReference` from `Directory.Build.props`,
conditioned to exclude the analyzer project itself (and its own build, to avoid a self-reference
cycle). Analyzer projects target `netstandard2.0`, so `ReferenceOutputAssembly="false"` +
`OutputItemType="Analyzer"` is the correct incantation — the analyzer DLL is loaded into the
compiler host, not linked. This makes OTOPCUA0001 run on every `src/` and `tests/` compilation.

Add to `Directory.Build.props` (after the existing `ItemGroup`):
```xml
<ItemGroup Condition="'$(MSBuildProjectName)' != 'ZB.MOM.WW.OtOpcUa.Analyzers'
                       and '$(MSBuildProjectName)' != 'ZB.MOM.WW.OtOpcUa.Analyzers.Tests'">
  <ProjectReference Include="$(MSBuildThisFileDirectory)src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false"
                    PrivateAssets="all" />
</ItemGroup>
```
(The `.Analyzers.Tests` exclusion is belt-and-suspenders: it already `ProjectReference`s the analyzer
directly; letting `Directory.Build.props` add a second analyzer-typed reference is harmless but noisy.)

**Triage sub-step (required, do NOT skip):** OTOPCUA0001 is a diagnostic. Its default severity must
be checked in `UnwrappedCapabilityCallAnalyzer` — if it is `Warning` and a src project has TWE on,
the wired-in analyzer can **break the build** on the first unwrapped call it finds. So the wiring
must be done in this order:
1. Wire the `ProjectReference` (above).
2. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` and capture every OTOPCUA0001 hit.
3. For each hit, decide: real violation → route the call through `CapabilityInvoker`; intentional
   (test doubles, the invoker's own internals) → add a scoped `#pragma warning disable OTOPCUA0001`
   or a per-project `<NoWarn>OTOPCUA0001</NoWarn>` with a comment. The analyzer's own tests
   demonstrate the intended pattern.
4. Only after the tree is clean should any project promote OTOPCUA0001 to error (out of scope here).

**Files to touch:** `Directory.Build.props` (add ItemGroup); plus any `.csproj` needing `NoWarn`
and any source files with genuine unwrapped calls surfaced by step 2.

**Verify:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` shows OTOPCUA0001 diagnostics on real code (proving
it is live); a deliberately-unwrapped call in a scratch file trips it; the analyzer's 31 tests still
pass. Confirm the build graph does not regress (`dotnet build` clean after triage).

**Effort:** M (wiring is S; the triage wave is the unknown — could be S or M depending on how many
unwrapped calls exist). **Risk:** Medium — a Warning-severity analyzer on TWE projects can fail the
build; the ordered triage above contains it. Blast radius is every compilation, so build once
locally before pushing.

---

### S-1 (High) — CI gates ~15% of the test matrix

**Restatement:** `v2-ci.yml` runs 5 of 47 unit-test projects; Client (388 tests), Analyzers (31),
all driver and most Core suites never run in CI.

**Verification:** confirmed (5 enumerated, 47 total).

**Root cause:** hand-maintained matrix predates most projects and was never widened; new projects
drift out of coverage silently.

**Design:** replace the hand-maintained `unit-tests` matrix with a single solution-wide leg that
excludes only the env-gated tiers. The skip-gated `*.IntegrationTests` fixtures already tolerate
unreachable endpoints (they `Assert.Skip`), so running them here is safe; but to keep the unit leg
fast and deterministic, split responsibilities:

- **`unit-tests` job:** one step —
  `dotnet test ZB.MOM.WW.OtOpcUa.slnx --configuration Release --no-restore
   --filter "Category!=E2E&Category!=LiveIntegration" --logger "trx;LogFileName=unit.trx"`
  after an explicit `dotnet restore` + `dotnet build --no-restore` (share build via `--no-build`
  where possible; see P-1). This runs all 47 projects in one invocation — no matrix to drift.

Rationale for the whole-solution filter over regenerating a matrix from the slnx: it is
self-maintaining (new project = automatically covered), matches CLAUDE.md's own
`dotnet test ZB.MOM.WW.OtOpcUa.slnx` guidance, and the trx output feeds S-2's skip-gate. The
integration split (Host.IntegrationTests / OpcUaServer.IntegrationTests as a distinct matrix leg)
stays, but is folded into the fail-on-skip work below (S-2).

**Files to touch:** `.github/workflows/v2-ci.yml` — replace the `unit-tests` job body.

**Verify:** push to a branch; the `unit-tests` job log shows Client.CLI/Shared/UI + Analyzers +
driver + Core suites executing (not skipped); wall-clock is acceptable (pair with P-1 caching).

**Effort:** S. **Risk:** Medium — first full-solution CI run will surface latent failures /
flakes that were never gated (esp. the fixed-sleep timing tests, S-4). That is the point, but budget
time to stabilize. Blast radius: CI only.

---

### S-2 (High) — "green" integration CI means "skipped"; skip is indistinguishable from pass

**Restatement:** the integration job runs fixture-less against `10.100.0.35` defaults → probe fails
→ `Assert.Skip` → job green. A real fixture outage cannot be told from a real pass.

**Verification:** confirmed — no `services:` in the integration job; fixtures hard-default to
`10.100.0.35` (Modbus/AbServer/Snap7/OpcPlc fixtures + `DriverTestConnectE2eTests`).

**Root cause:** the probe-once skip pattern is correct for dev boxes, but CI has no signal that the
tier ran nothing.

**Design — two complementary changes:**

1. **Make skips visible/failing.** Emit `--logger "trx"` from the integration leg and add a post-test
   step that parses the trx and **fails the job when skipped-count exceeds a threshold** (e.g. any
   integration test skipped when CI was supposed to have fixtures, or > N total). A small inline shell
   or a tiny `scripts/ci/assert-not-all-skipped.ps1`/`.sh` reading the `<Results>`/`outcome="NotExecuted"`
   counts. This is the minimal, always-applicable guard.

2. **(Optional, higher value) Actually start the reachable fixtures as workflow `services:`.** The
   Modbus sim and opc-plc images run on any hosted runner; add them as `services:` and set
   `MODBUS_SIM_ENDPOINT=localhost:5020` / `OPCUA_SIM_ENDPOINT=opc.tcp://localhost:50000` so those
   probes connect and the tests actually run. S7/AbCip/Galaxy fixtures have no hosted-runner image and
   stay legitimately skipped (guard #1 excludes them from the threshold, or scopes the threshold to
   the modbus/opc-plc subset).

Recommend shipping **guard #1 first** (cheap, universal), then #2 as a follow-up.

**Files to touch:** `.github/workflows/v2-ci.yml` (integration job: add trx logger, post-step, and
optionally `services:` + endpoint env); optionally a new `scripts/ci/assert-not-all-skipped.*`.

**Verify:** deliberately point an endpoint at an unreachable host in a branch → integration job goes
**red** (not green). With services up, the modbus/opc-plc tests show as passed, not skipped.

**Effort:** S (guard #1) / M (with services). **Risk:** Low-Medium — a too-tight threshold could
red-flag legitimately-unavailable fixtures; scope the threshold to the images CI can actually run.

---

### S-3 (Medium) — nightly E2E workflow is a permanent green no-op

**Restatement:** `v2-e2e.yml` filters `Category=E2E`, which matches zero tests; it boots the full
docker-dev fleet for nothing and trains people to ignore a nightly green.

**Verification:** confirmed — zero `Category=E2E` in the tree; the workflow header admits it.

**Root cause:** the E2E project (`tests/Server/ZB.MOM.WW.OtOpcUa.E2ETests`) was scoped but never
landed.

**Design (pick one):**
- **(a) Disable the schedule until the project exists** — comment out the `schedule:` trigger, keep
  `workflow_dispatch`, and add a guard step that fails if `Category=E2E` matches zero tests (so the
  no-op can't silently return). Lowest effort; removes the false-green.
- **(b) Land a minimal E2E round-trip** — a single `[Trait("Category","E2E")]` test that, against the
  booted docker-dev fleet, connects via `Client.Shared`/`Client.CLI` to `opc.tcp://localhost:4840`,
  browses to a known seeded node, reads a value, and asserts Good. The `scripts/e2e/test-*.ps1`
  harnesses show exactly what to assert. This gives the nightly job real meaning and exercises the
  otherwise-uncovered Client stack end-to-end.

Recommend **(a) now** (removes the false signal immediately) with **(b) as the tracked follow-on** —
option (a)'s zero-match guard also protects (b) from regressing back to a no-op.

**Files to touch:** `.github/workflows/v2-e2e.yml`; for (b) a new `tests/Server/…E2ETests` project +
slnx entry.

**Verify:** (a) manual dispatch fails-fast on zero E2E tests; (b) the round-trip test passes against
the fleet and fails if the seeded node is absent.

**Effort:** S (a) / M-L (b). **Risk:** Low.

---

### S-7 (Low) — no `global.json` though the build depends on an exact SDK band

**Restatement:** `v2-ci.yml:10-12` claims a repo-root `global.json` pins the SDK; none exists.
Meanwhile `Directory.Packages.props:44-49` pins Roslyn 5.0.0 to match SDK 10.0.105's compiler.

**Verification:** confirmed — no `global.json`; the CI comment is false.

**Root cause:** the pin comment describes intended state that was never created; the Roslyn pin note
implicitly depends on SDK version stability the repo doesn't enforce.

**Design:** add a root `global.json`:
```json
{
  "sdk": {
    "version": "10.0.105",
    "rollForward": "latestFeature"
  }
}
```
`latestFeature` allows patch/feature SDK rolls within the 10.0.1xx band but keeps the major/minor
stable, so the Roslyn 5.0.0 pin's stated condition ("until the SDK rolls to 10.0.110+") becomes a
deliberate, visible decision rather than a silent drift. Cross-check the exact installed SDK version
before committing the `version` value. Update the Roslyn-pin comment in `Directory.Packages.props` to
reference `global.json` as the coupling point.

**Files to touch:** new `global.json`; comment tweak in `Directory.Packages.props`.

**Verify:** `dotnet --version` in-repo resolves within the band; CI `setup-dotnet` honors it (the
workflow comment becomes true). Build + tests unchanged.

**Effort:** S. **Risk:** Low — a too-strict `version`/`rollForward` could fail a runner lacking that
exact SDK; `latestFeature` mitigates.

---

## TIER B — Repo hygiene

### U-5 (Medium) — orphaned proprietary AVEVA DLLs tracked in `lib/`

**Restatement:** 7 committed vendor binaries in `lib/`, referenced by nothing — a
redistribution/licence risk in every clone.

**Verification:** confirmed — `git ls-files lib/` lists all 7; zero `HintPath` hits repo-wide.

**Root cause:** leftovers from the retired in-process MXAccess / Wonderware sidecar era; the
bitness/COM story now lives entirely in the mxaccessgw repo (per CLAUDE.md).

**Design:** `git rm -r lib/`. Before deleting, re-confirm nothing references them:
```
grep -rn "aahClient\|ArchestrA\|Historian.CBE\|Historian.DPAPI\|HintPath.*lib" \
  --include='*.csproj' --include='*.props' --include='*.targets' .
```
(expected: no hits outside the review docs). Optionally add `lib/` to `.gitignore` to prevent
re-introduction, and note in the commit that the DLLs remain retrievable from history / the AVEVA SDK
if ever needed. Purging them from *history* is a separate, heavier decision (BFG/filter-repo) — out
of scope; the immediate licence-hygiene win is removing them from the tip.

**Files to touch:** delete `lib/*` (7 files); optional `.gitignore` entry.

**Verify:** `git status` clean; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` still succeeds (proves nothing
linked them); `git ls-files lib/` empty.

**Effort:** S. **Risk:** Low — verified zero references; blast radius nil.

---

### U-4 (Medium) — root planning-file sprawl contradicts its own "never stage" rule

**Restatement:** `pending.md`, `current.md`, `looseends.md`, `stillpending.md`,
`HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md` are committed at root; `pending.md:3` itself says never
stage `pending.md`/`current.md`. Content is stale snapshots now contradicting `docs/` + CLAUDE.md.

**Verification:** confirmed — all 5 tracked; the hard-rule text present.

**Root cause:** working-notes files that were staged despite the rule, and never cleaned up as the
backlog shipped (~95% per the memory index).

**Design — make the file's own rule true:**
1. **`pending.md` + `current.md`** — untrack but keep locally (they're active working notes):
   `git rm --cached pending.md current.md`, then add both to `.gitignore`. This exactly satisfies
   the "never stage" rule.
2. **`looseends.md`, `stillpending.md`, `HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md`** — these are
   point-in-time snapshots of shipped work. Preferred: move to `docs/plans/` with dated names (the
   established convention), e.g. `docs/plans/2026-05-18-looseends.md`, so the history is preserved
   as an archived plan rather than a live-looking root file. Acceptable alternative: `git rm` them
   outright since the memory index records the backlog as shipped and `docs/`+CLAUDE.md now carry the
   authoritative state. Recommend **archive-move** for `stillpending.md`/`HISTORIAN-GATEWAY-…` (they
   have referenced history) and **delete** for `looseends.md` if fully superseded — confirm with a
   quick read before choosing per-file.

**Files to touch:** `.gitignore` (+2 entries); `git rm --cached` pending/current; move-or-delete the
3 snapshots; `docs/plans/` if archiving.

**Verify:** `git ls-files | grep -E '^(pending|current|looseends|stillpending|HISTORIAN)'` returns
only any intentionally-archived `docs/plans/*` paths; `git status` shows pending.md/current.md now
untracked (ignored). `pending.md`'s hard-rule is now consistent with reality.

**Effort:** S. **Risk:** Low — pure VCS hygiene; keep local copies (`--cached`) so no working data is
lost.

---

### U-6 (Low) — secrets hygiene + retired-dir husks

**Restatement:** `sql_login.txt` is NOT committed (gitignored) but is a plaintext credential on
Desktop; retired `Driver.Historian.Wonderware*` dirs linger on disk as bin/obj husks.

**Verification:** confirmed — `sql_login.txt` `.gitignore:47`, `git log` empty; the 8
`Driver.Historian.Wonderware*` dirs under `src/`+`tests/` are untracked (only `docs/` stub + 3
`code-reviews/…/findings.md` are tracked residue).

**Root cause:** local dev debris; the credential file is a convenience left in the repo dir.

**Design:**
- **`sql_login.txt`** — no tracking action needed (already ignored). Recommend moving the credential
  to `dotnet user-secrets` or an env file *outside* the repo directory, and audit
  `export-clean-copy.bat` at root to confirm it excludes `sql_login.txt` (and `lib/`, pki, planning
  files) from any export bundle. Low-priority, local-only.
- **Wonderware husks** — delete the untracked `src/Drivers/…Wonderware*` and
  `tests/Drivers/…Wonderware*` directories from disk (`rm -rf`, they're pure `bin/obj`). Decide
  separately whether to also remove the 3 tracked `code-reviews/Driver.Historian.Wonderware*/`
  findings and keep `docs/drivers/Historian.Wonderware.md` as the intentional retired stub (the doc
  is deliberately retained per CLAUDE.md; leave it).

**Files to touch:** none tracked for the credential; `rm -rf` the untracked husks; audit
`export-clean-copy.bat`.

**Verify:** `find src tests -type d -iname '*Wonderware*'` returns nothing; `git status` unchanged by
the husk deletion (they were untracked); `export-clean-copy.bat` excludes the sensitive files.

**Effort:** S. **Risk:** Low.

---

### C-5 (Low) — no `.editorconfig`; StyleGuide.md mis-titled for a sister repo

**Restatement:** no mechanical code-style enforcement; `StyleGuide.md:3` still says "for all
**ScadaBridge** documentation" (copy-paste from the sibling repo).

**Verification:** confirmed — no `.editorconfig`; `StyleGuide.md:3` wording present.

**Root cause:** style held by review culture only; StyleGuide copied from ScadaBridge without
re-titling.

**Design — two independent, small changes:**
1. **StyleGuide fix (trivial, do now):** replace "ScadaBridge" with "OtOpcUa" in `StyleGuide.md:3`
   (and scan the file for other ScadaBridge leftovers).
2. **`.editorconfig` (optional):** add a root `.editorconfig` codifying the *already-consistent*
   conventions — file-scoped namespaces, 4-space indent, `_camelCase` private fields, `var` usage —
   set as `suggestion`/`warning` (not `error`) to avoid a big-bang reformat. This is documentation-of-
   convention, not a reformatting mandate; a `dotnet format` CI step is a further follow-on (U-7),
   deliberately deferred so it doesn't churn the tree.

**Files to touch:** `StyleGuide.md`; optional new `.editorconfig`.

**Verify:** StyleGuide title reads OtOpcUa; `.editorconfig` doesn't flip existing warnings to errors
(build unchanged).

**Effort:** S. **Risk:** Low (keep severities non-error).

---

## TIER C — Documentation

### U-3 (Medium) — `docs/Client.CLI.md` documents 8 of 14 commands

**Restatement:** `ack`/`acknowledge`, `confirm`, `shelve`, `enable`, `disable` have no doc sections,
though all ship and CLAUDE.md points operators at this doc.

**Verification:** confirmed — `confirm`/`shelve`/`disable` = 0 mentions; `ack`/`enable` only
substring hits; command files exist for all 5.

**Root cause:** the inbound operator ack/shelve feature shipped its runtime + CLI but the CLI doc was
never extended.

**Design:** add five command sections to `docs/Client.CLI.md` mirroring the existing section shape
(synopsis, options, example invocation, expected output). Source the exact options/args from the
command classes: `AcknowledgeCommand.cs`, `ConfirmCommand.cs`, `ShelveCommand.cs`, `EnableCommand.cs`,
`DisableCommand.cs` (and their shared `CommandBase` connection options). Group them under an "Alarm
operator commands" heading since they are the operator-facing ack/shelve workflow. Cross-check against
`docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" for terminology consistency.

**Files to touch:** `docs/Client.CLI.md`.

**Verify:** each of the 5 commands has a section; a `-h`/`--help` run of each command matches the
documented options. Optionally add a CI docs-coverage check (U-7 follow-on) asserting every
`Commands/*Command.cs` has a doc section.

**Effort:** S. **Risk:** Low.

---

## TIER D — Engineering debt (as capacity allows)

These are real but lower-leverage than restoring CI meaning. Batched with condensed treatment.

### C-2 (Medium) — TWE never reached the Client/Tooling slice
Add `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>` to `Client.CLI.csproj`,
`Client.Shared.csproj`, `Client.UI.csproj`, `Analyzers.csproj` (v2-era code; the legacy xUnit1051
blocker doesn't apply). **Do this AFTER C-1's triage** — with the analyzer wired in, a Warning-level
OTOPCUA0001 would now fail these builds, so sequence C-1 triage → C-2. Verify each builds clean with
TWE on. Effort S; Risk Low (build once locally first). Burn down the 38 test-project TWE gaps per
module as a tracked follow-on.

### P-1 (Medium) — CI does 7 uncached restore+build passes
Add `actions/cache` for `~/.nuget/packages` keyed on a hash of `Directory.Packages.props` +
`NuGet.config`; do one explicit `restore`, then `build --no-restore`, then `test --no-build` in the
unit leg (dovetails with S-1's single-leg design). Effort S; Risk Low; biggest CI wall-clock win.

### U-2 (Medium) — add `Host.Tests` (the one real coverage hole)
Create `tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests` asserting DI composition — e.g. "when
`ServerHistorian:Enabled`, `IHistorianProvisioning` resolves to `GatewayTagProvisioner` and the
applier receives it" — the exact class of dormant-wiring bug (PR #423, DeferredAddressSpaceSink) that
unit tests would have caught. Add to slnx so S-1's whole-solution CI picks it up. Effort M; Risk Low.

### S-5 (Medium) — Client.Shared subscription lock-discipline gaps
In `OpcUaClientService.cs`: (a) move the `_dataSubscription` null-check/create inside an async gate
mirroring `_alarmSubscribeSemaphore` (line ~262); (b) take `_subscriptionLock` in `RunFailoverAsync`
when nulling `_dataSubscription`/`_alarmSubscription` (lines ~695-696); (c) route
`UnsubscribeAlarmsAsync` (line ~335) through `_alarmSubscribeSemaphore`; (d) observe exceptions from
the fire-and-forget keep-alive failover. Near-unhittable for the session-per-command CLI; real for the
long-lived Client.UI service. Effort M; Risk Medium (concurrency code — add targeted tests). Do before
Client.UI grows multi-threaded usage.

### S-6 (Medium) — global `Log.Logger` swap per CLI command
`CommandBase.ConfigureLogging()` (`:120-134`) does `Log.CloseAndFlush()` + replaces static
`Log.Logger` per command — a race under xunit.v3's parallel collections and hostile to embedding.
Give each command an instance `ILogger` from a per-execution `LoggerConfiguration.CreateLogger()`.
The existing `LoggerLifecycleTests` polices this. Effort M; Risk Low-Medium (touches every command's
logging).

### S-4 (Medium) — fixed-sleep timing tests
Replace start-up `await Task.Delay(100)` sleeps in `Client.CLI.Tests` (`SubscribeCommandTests`,
`AlarmsCommandTests`, `EventHandlerLifecycleTests`) with a readiness signal — a `TaskCompletionSource`
completed on first `SubscribeAsync` exposed by `FakeOpcUaClientService`. Currently masked because CI
never runs them (S-1); will flake once S-1 lands, so **pair with S-1**. Effort M; Risk Low.

### C-4 (Low) — finish xunit.v3 migration
Migrate the 3 holdouts (`AdminUI.Tests`, `ControlPlane.Tests`, `Runtime.Tests`) off xunit 2.9.2, then
drop the `xunit`/`xunit.runner.visualstudio` 2.x pins from `Directory.Packages.props:108-109`.
Optionally relocate `GatewayLiveIntegrationTests` to a `*.IntegrationTests` sibling. Effort M; Risk
Low.

### C-6 (Low) — csproj boilerplate duplicates Directory.Build.props
Strip redundant `TargetFramework`/`Nullable`/`ImplicitUsings` from the ~70 csprojs on next sweep so an
SDK bump touches one file. Effort M (mechanical); Risk Low. Low priority.

### S-8 (Low) — first-caller interval wins for shared data subscription
Document the shared-publish-interval behavior in `docs/Client.CLI.md`/`docs/Client.UI.md`, or create
per-interval subscriptions if UI users mix cadences. Effort S; Risk Low.

### U-8 (Low) — CLI security posture is dev-tool-grade
Document that `AutoAcceptCertificates = true` (`CommandBase.cs:91`) means `--security signandencrypt`
encrypts but does not authenticate the server (MITM-able), and that `-P` is process-visible. Optionally
add a `--strict-certs` opt-in. Separately, note the Galaxy-specific alarm attribute names baked into
the generic `OpcUaClientService.cs:851-871` as a documented layering compromise. Effort S; Risk Low.

### P-2 (Low) — recursive browse N+1
`BrowseAsync` issues an extra `HasChildrenAsync` per Object child (`:230-231`) and
`SubscribeCommand.CollectVariablesAsync` walks serially. Batch/bulk-read references only if `subscribe
-r` start-up becomes a soak-test bottleneck. Effort M; Risk Low. Defer.

### U-7 (Low) — missing CI stages inventory
Tracking item: analyzer enforcement (C-1), all-suite coverage (S-1), `dotnet format`/style step,
explicit failing NuGet-audit step, running `scripts/check-code-reviews-readme.ps1` in CI, and a docs
freshness/link check. Also the `ci/ab-server.lock.json` references a non-existent "GitHub Actions
step" in `docs/v2/test-data-sources.md` — either land the AB fixture download step or fix the doc.
Address incrementally as the above land.

---

## No-action (verified positive / accurate — recorded only)

- **P-3** — probe-once collection-fixture pattern is well-engineered; the issue is what CI does with
  it (S-2), not the fixtures.
- **P-4** — CLI subscription output uses a channel-serialised writer; allocation-sane. A bounded
  `DropOldest` channel is a cosmetic future option.
- **C-3** — CPM discipline exemplary; the `NuGetAuditSuppress` carve-out and the two transitive CVE
  pins are still valid and accurately scoped. Keep the removal reminders alive.
- **U-1** — Client.UI is a genuine, adequately-tested product (not vestigial); "maturing", not
  "underdeveloped". Its only gap is the same CI/E2E gap (S-1/S-3).

---

## Suggested execution sequence

1. **C-1** (wire analyzer) + its triage — establishes the enforcement mechanism; must precede C-2.
2. **S-1 + P-1** (whole-solution CI + caching) in one workflow edit — biggest coverage win.
3. **S-2** (fail-on-skip guard, then optional `services:`) — closes the false-green.
4. **S-7** (`global.json`) + **S-3** (disable E2E schedule / zero-match guard) — small CI truth fixes.
5. **U-5, U-4, U-6, C-5** (hygiene batch) — one commit; verified zero-reference deletions.
6. **U-3** (Client.CLI doc) — five sections.
7. **C-2** (TWE, after C-1 triage), then Tier-D items as capacity allows, pairing **S-4 with S-1**.

Each of steps 1-4 must be validated by an actual CI run on a branch before merge — per the report's
own theme #4, green CI here has been meaningless, so the fixes must be observed to change the badge's
behavior (a broken build/deliberate skip must now go red).
