Files
lmxopcua/archreview/plans/01-core-composition-plan.md
T
Joseph Doherty e31d29c04c docs(archreview): add STATUS.md handoff + per-plan status banners
Track remediation progress: 2 of 4 Criticals done (03/S1 split-brain resolver;
02/U2+U3 VT timeout+ALC) + both CLAUDE.md doc-drifts fixed. STATUS.md is the
single source of truth (branch topology, completed items, task list, next-up,
resume facts). Each plan carries a status banner.
2026-07-08 16:25:39 -04:00

537 lines
51 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.
# Design + Implementation Plan — Core & Composition Pipeline
> **Status (2026-07-08):** not started. Next highest-leverage here: S-1 (AddressSpaceApplyOutcome failure
> field — failure-visibility) and C-1 (`TagConfigIntent.Parse` in Commons). See [`STATUS.md`](STATUS.md).
**Domain report:** `archreview/01-core-composition.md`
**Baseline commit reviewed:** `9cad9ed0` (master)
**Plan author verification date:** 2026-07-08
**Scope:** Core, Core.Abstractions, Commons, Configuration, Cluster; AddressSpace Composer/Planner/Applier.
## Verification summary
All 21 findings were opened at their cited file:line against the current tree. **Every finding is CONFIRMED
accurate** — none are stale or already-fixed. (One nuance: U-3's *delta feed* half is already implemented and
shipped, matching the report's own "delta feed landed, initial set still empty" framing — so U-3 is confirmed as
partially-done debt, not stale.)
Key structural fact that shapes the C-1/C-3 fix: **`Commons` is a pure leaf project** (zero `ProjectReference`s,
verified from `Commons.csproj`), and **`Configuration` does not currently reference it** but *can* without creating a
cycle (Commons references neither Configuration nor Core). This makes "move shared parsing/scheme into Commons" the
correct, low-risk home for the byte-parity duplication class.
## Priority ordering (this plan)
Ordered by severity, then by the OVERALL prioritized action list (items #7 and #10 touch this domain directly).
| Order | ID | Sev | Title | Effort | OVERALL ref |
|---|---|---|---|---|---|
| 1 | S-1 | High | Applier swallows sink failures; deploy reports success on broken address space | M | Action #7 |
| 2 | C-1 | High | TagConfig parsing byte-parity-replicated in 4 projects | M | Action #10 |
| 3 | C-3 | Med | DraftValidator re-derives NodeId scheme + hard-codes driver-type string | S | Action #10 |
| 4 | P-1 | Med | Compose parses each TagConfig JSON 4× | S (folds into C-1) | — |
| 5 | S-2 | Med | `PollGroupEngine.Unsubscribe` blocks caller up to 5 s | M | — |
| 6 | P-2 | Med | Per-operation allocations on authorization hot path | S | — |
| 7 | P-3 | Med | PollGroupEngine task-per-subscription, no coalescing | L (doc-only now) | — |
| 8 | C-2 | Med | Core → Configuration coupling (accepted-risk, document) | S | — |
| 9 | U-1 | Med | Dead/dormant code: GDNM, EquipmentNodeWalker, TryParseRelayBody | S | Action #12 |
| 10 | U-2 | Med | Tier C recycle machinery has no `IDriverSupervisor` impl | S | Action #12 |
| 11 | U-3 | Med | Continuous-historization initial ref set still empty at spawn | M | — |
| 12 | P-4 | Low | Per-write allocation in non-idempotent write path | S | — |
| 13 | S-3 | Low | `Subscribe`/`RegisterAsync` have no disposed guard | S | — |
| 14 | S-4 | Low | ScheduledRecycleScheduler catch-up storm after stall | S | — |
| 15 | S-5 | Low | GDNM re-walk teardown not synchronized (moot — dead code) | — (folds into U-1) | — |
| 16 | C-4 | Low | Composer violates purity with `Trace.TraceWarning` | S | — |
| 17 | C-5 | Low | Inconsistent duplicate-key defensiveness in Compose | S | — |
| 18 | C-6 | Low | Vestigial Akka package reference in Commons | S | — |
| 19 | C-7 | Low | Stale scaffold-era XML docs on load-bearing classes | S | — |
| 20 | U-4 | Low | Additive-only ACL model (no Deny) — documented v2.0 gap | S (doc) | — |
| 21 | U-5 | Low | Uneven test coverage (ClusterRoleInfo untested) | M | — |
---
## 1. S-1 — Applier swallows every sink failure; deploy reports applied even when address space is broken (High)
**Verification: CONFIRMED.**
- `AddressSpaceApplyOutcome` (`AddressSpaceApplier.cs:691`) carries only `RemovedNodes/AddedNodes/ChangedNodes/RebuildCalled`**no failure count**.
- `SafeRebuild` (`:373-383`) catches every exception from `_sink.RebuildAddressSpace()` and only logs; the caller still sets `rebuilt = true` (`:150-153`).
- `SafeEnsureFolder`/`SafeEnsureVariable` (`:612-622`), `SafeWriteAlarmCondition`/`SafeMaterialiseAlarmCondition` (`:677-687`) swallow per-node failures into Warnings.
- Consumer confirmed: `OpcUaPublishActor.cs:335` calls `_applier.Apply(plan)` and **only logs** the outcome (`:357`). The whole rebuild path is itself inside a try/catch that logs (`:360-363`). There is no `ApplyAck`/`DeploymentFailed` reply carrying success/failure back to a deploy coordinator — the deploy seals independently of apply health.
**Root cause.** The "never fail a deploy" posture (correct for the *detached* provisioning/historized-ref hooks) was applied uniformly, including to the structural materialisation that is the deploy's core contract. The outcome record has no channel to express partial/total failure, so the publish actor has nothing to act on even if it wanted to.
**Proposed design.** Give the applier a truthful failure surface and route it to an operator-visible signal. This is the domain-01 slice of OVERALL cross-cutting theme #3 ("optimistic success").
Two-part approach:
1. **Count failures in the outcome.** Change the `Safe*` helpers to return a `bool` (or increment a private counter) and add `RebuildFailed` + `FailedNodes` to `AddressSpaceApplyOutcome`. `SafeRebuild` returning false must flip `rebuilt`'s meaning: keep `RebuildCalled` (it *was* attempted) but set `RebuildFailed = true` so callers can distinguish "rebuilt OK" from "rebuild threw".
2. **Surface it.** In `OpcUaPublishActor`, when `outcome.RebuildFailed || outcome.FailedNodes > 0`, log at `Error` (not Info) **and** emit an operator-visible signal. Reuse the existing telemetry meter `OtOpcUaTelemetry.OpcUaSinkWrite` with a `kind:"apply-failed"` tag (pattern already used at `OpcUaPublishActor.cs:303,356`), and — if a deploy-status/alert channel is reachable from the actor — post a degraded status. Do **not** convert this into a hard deploy abort in v1: a partially-materialised address space is still better than none, and the publish actor is already past the seal decision. The goal is *visibility*, matching the report's "escalate `SafeRebuild` failure to at least a degraded ack."
**Alternatives considered.**
- *Throw from Apply on rebuild failure* — rejected: the actor's catch would swallow it anyway and it removes the counts other callers want; also risks aborting the subsequent `Materialise*` passes that could still succeed.
- *Full ApplyAck→DeploymentFailed wiring* — larger blast radius touching ControlPlane deploy coordination; deferred. The OVERALL list scopes item #7 as "add a failure field ... surface"; the deploy-abort decision belongs with report 03's deploy-coordination work.
**Implementation steps.**
- `AddressSpaceApplier.cs`:
- Extend record: `AddressSpaceApplyOutcome(int RemovedNodes, int AddedNodes, int ChangedNodes, bool RebuildCalled, bool RebuildFailed, int FailedNodes)`. Keep a 4-arg → 6-arg default or update the two construction sites (`:80` empty-plan, `:215` main return).
- `SafeRebuild()``private bool SafeRebuild()` returning `false` on catch; caller records `rebuildFailed`.
- `SafeEnsureFolder`/`SafeEnsureVariable`/`SafeWriteAlarmCondition`/`SafeMaterialiseAlarmCondition` → return `bool`; the `Materialise*` passes tally a `_failedNodes` counter. Because the `Materialise*` passes run from `OpcUaPublishActor` *after* `Apply` returns (they are separate public methods, `:338-354`), expose the tally via either (a) an out-param/return on each `Materialise*` method, or (b) an instance counter reset at the start of the rebuild sequence and read after. Prefer (a) — each `Materialise*` returns an `int failedNodes`; the actor sums them.
- `OpcUaPublishActor.cs` (`:335-358`): capture the failure tally across `Apply` + the four `Materialise*` calls; branch to `_log.Error` + `OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, kind:"apply-failed")` when non-zero.
**Tests (unit).** `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpace*`:
- A throwing fixture sink (the `NullOpcUaAddressSpaceSink` pattern extended to throw on `RebuildAddressSpace` / `EnsureVariable`): assert `outcome.RebuildFailed == true` when rebuild throws, and `FailedNodes == N` when N `EnsureVariable` calls throw during a materialise pass.
- Assert the happy path still reports `RebuildFailed == false, FailedNodes == 0`.
**Effort: M. Risk: low-medium** — record shape change ripples to every `Apply` caller + test asserting the record; contained to OpcUaServer + Runtime.
---
## 2. C-1 — TagConfig parsing byte-parity-replicated in four projects (High)
**Verification: CONFIRMED.** Four independent copies of the top-level `FullName` (and sibling `alarm`/`isHistorized`/`isArray`/`HostAddress`) parsing:
- `AddressSpaceComposer.cs:536-551` (`ExtractTagFullName`) + `:601-686` (`ExtractTagAlarm`/`ExtractTagHistorize`/`ExtractTagArray`) + `:567` (`TryExtractDeviceHost`).
- `Runtime/Drivers/DeploymentArtifact.cs` (artifact-decode mirror; comment at `:666` cites `EquipmentNodeWalker.ExtractFullName`).
- `Core/OpcUa/EquipmentNodeWalker.cs` `ExtractFullName` (dormant — see U-1).
- `Configuration/Validation/DraftValidator.cs:60-71` (`ExtractTagConfigFullName`, "a small local copy").
Each copy carries a "MUST parse identically (byte-parity)" comment. `EquipmentScriptPaths` (`Commons/Types/`) is the in-repo precedent for de-duplicating exactly this kind of cross-seam parser.
**Root cause.** OpcUaServer does not reference the Core driver assembly and Configuration does not reference Commons, so no shared home existed — each seam grew its own parser, synced by human-enforced comments. This is the OVERALL cross-cutting theme #2 ("fixes don't flow back to shared templates") in the Core layer.
**Proposed design.** Create a single `TagConfigIntent` parser in **Commons** (leaf, no EF/SDK deps — verified) that parses the blob **once** and returns all intents, then retrofit every seam onto it. This simultaneously resolves **P-1** (one parse instead of four).
```csharp
// src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
public sealed record TagConfigIntent(
string FullName, // top-level "FullName" or raw blob fallback
TagAlarmIntent? Alarm, // "alarm" object or null
bool IsHistorized, string? HistorianTagname,
bool IsArray, uint? ArrayLength)
{
public static TagConfigIntent Parse(string? tagConfig); // single JsonDocument.Parse, never throws
}
public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva);
```
Add a companion `DeviceConfigIntent.TryExtractHost(string?)` (or fold `TryExtractDeviceHost` + `NormalizeDeviceHost` into Commons) so the device-host normalization single-source-of-truth also lives there.
**Why Commons, and why one record.** The report's own recommendation, and the `EquipmentScriptPaths` precedent, both point here. A single record parsed once (a) kills the 4× duplication, (b) collapses P-1's 4 parses/tag → 1, and (c) lets the byte-parity contract be enforced by **one** cross-seam parity test instead of N.
**Alternatives considered.**
- *Put it in Core.Abstractions* — rejected: DeploymentArtifact (Runtime) and DraftValidator (Configuration) would still need it; Commons is the common ancestor all of them can see (or cheaply add).
- *Keep separate parsers, add a shared golden-file parity test* — rejected: tests the symptom, not the cause; still 4 code paths to drift.
**Implementation steps.**
1. Add `Commons/Types/TagConfigIntent.cs` (+ `DeviceConfigIntent` helper). Port the *exact* current parse semantics verbatim from `AddressSpaceComposer` (the canonical copy) — especially the raw-blob fallback for `FullName`, the `historizeToAveva` bool? default-on rule, and the `arrayLength`-only-when-`isArray` rule — so no byte-parity behavior changes.
2. `AddressSpaceComposer.cs`: replace the per-tag `ExtractTagFullName`/`ExtractTagAlarm`/`ExtractTagHistorize`/`ExtractTagArray` calls (`:418-433`) with a single `var intent = TagConfigIntent.Parse(t.TagConfig);` then project fields off `intent`. Replace `TryExtractDeviceHost`/`NormalizeDeviceHost` usages with the Commons helper. Delete the now-private methods.
3. `Runtime/Drivers/DeploymentArtifact.cs`: replace its mirror `Extract*` methods with `TagConfigIntent.Parse`.
4. `Configuration/Validation/DraftValidator.cs`: add a `ProjectReference` to Commons (cycle-safe — verified), replace `ExtractTagConfigFullName` (`:60-71`) with `TagConfigIntent.Parse(t.TagConfig).FullName` (note: current DraftValidator returns null for the missing case; preserve that by checking `string.IsNullOrWhiteSpace` against the parsed FullName, being careful that the Commons parser returns the raw blob as fallback — the Galaxy check wants the *explicit* FullName, so either expose a `HasExplicitFullName` bool on the intent or keep DraftValidator's null-on-absent semantics by adding a `TagConfigIntent.TryGetExplicitFullName`).
5. `EquipmentNodeWalker.ExtractFullName`: deleted with the walker under U-1 (or repointed at Commons if the walker survives as a test fixture).
**Tests.**
- `Commons.Tests`: exhaustive `TagConfigIntent.Parse` table (object/non-object/blank/malformed/each field present-absent-wrong-type) — this becomes the *single* byte-parity authority.
- Keep one cross-seam parity test asserting `AddressSpaceComposer` compose output and `DeploymentArtifact` decode output agree on a fixture set (this already exists in the OpcUaServer.Tests byte-parity suite — repoint it at the shared parser).
**Effort: M. Risk: medium** — touches the byte-parity contract, the crown-jewel invariant. Mitigation: port semantics verbatim, land behind the existing byte-parity test suite, no behavior change intended.
---
## 3. C-3 — DraftValidator re-derives NodeId scheme + hard-codes driver-type string (Medium)
**Verification: CONFIRMED.**
- `DraftValidator.cs:75-97` (`ValidateNoEquipmentSignalNameCollision`) hand-rolls the folder-scoped key `"{eq}/{folder}/{name}"` via a local `Key(...)` because Configuration can't reference `Commons.OpcUa.EquipmentNodeIds` (`Commons/OpcUa/EquipmentNodeIds.cs` confirmed to exist).
- `DraftValidator.cs:48` hard-codes `dtype != "GalaxyMxGateway"`.
**Root cause.** Same missing shared-contract home as C-1: dependency direction blocked Configuration from seeing the Commons scheme/constants.
**Proposed design.** Once C-1 adds the `Configuration → Commons` ProjectReference, this becomes trivial:
- Replace the local `Key(...)` in `ValidateNoEquipmentSignalNameCollision` with the same key `EquipmentNodeIds` produces (add a public `EquipmentNodeIds.Variable(eq, folder, name)` — it already exists per `AddressSpaceApplier.cs:177` usage — and call it, so the collision check keys on the *exact* materialiser scheme, eliminating drift risk).
- Replace `"GalaxyMxGateway"` with a shared driver-type constant. Add `DriverTypes.GalaxyMxGateway` (a `const string`) in Commons (or reuse an existing driver-type-names home if one exists — grep `DriverType` constants first). Point DraftValidator at it.
**Implementation steps.**
1. (Depends on C-1's `Configuration → Commons` reference.)
2. Ensure `EquipmentNodeIds.Variable` is public and matches DraftValidator's `null-folder ⇒ "{eq}/{name}"` behavior (verify against `AddressSpaceApplier.cs:177`). If the VirtualTag branch (which passes `folder=null`) needs the no-folder overload, add it.
3. Add `Commons` driver-type constant; replace the literal at `DraftValidator.cs:48`.
4. Grep for other `"GalaxyMxGateway"` literals (composer comment references it; `DriverFactoryBootstrap` registers it) and repoint the *logic* sites (not doc comments) at the constant.
**Tests.** Existing `Configuration.Tests` DraftValidator collision + Galaxy-FullName tests should pass unchanged (behavior-preserving). Add one asserting the collision key equals `EquipmentNodeIds.Variable(...)` for a folder + no-folder case, so the shared-scheme binding is guarded.
**Effort: S. Risk: low.**
---
## 4. P-1 — Compose parses each TagConfig JSON four times (Medium)
**Verification: CONFIRMED.** `AddressSpaceComposer.cs:418-433` invokes `ExtractTagHistorize`, `ExtractTagArray`, `ExtractTagFullName`, `ExtractTagAlarm` — each doing its own `JsonDocument.Parse` (`:541,606,638,670`). 4 parses/tag × 2 seams (compose + artifact-decode).
**Root cause / fix.** This is fully resolved by C-1's `TagConfigIntent.Parse` (single parse per tag per seam). No separate work item — **fold into C-1**. Note in the C-1 PR that P-1 is closed by the same change.
**Effort: S (subsumed). Risk: as C-1.**
---
## 5. S-2 — `PollGroupEngine.Unsubscribe` blocks the calling thread up to 5 s per subscription (Medium)
**Verification: CONFIRMED.** `StopState` (`PollGroupEngine.cs:99-112`) does synchronous `task.Wait(TimeSpan.FromSeconds(5))` (`:109`). `Unsubscribe` (`:89-97`) calls it inline. `DisposeAsync` (`:213-236`) does the correct parallel-cancel + awaited `WhenAll(...).WaitAsync(5s)`.
**Root cause.** The single-subscription teardown path was written synchronously to preserve the "no `_onChange` after teardown" guarantee, but blocks the caller (an OPC UA subscription-management callback / actor handler) while a reader is mid-slow-call. N serial unsubscribes = N × up-to-5 s.
**Proposed design.** Add `ValueTask UnsubscribeAsync(ISubscriptionHandle)` that cancels the CTS, then awaits `LoopTask.WaitAsync(timeout)` (mirroring `DisposeAsync`'s discipline) rather than blocking. Keep the synchronous `Unsubscribe` for compatibility but re-implement it to **cancel + hand the drain to a background continuation** that disposes the CTS after the loop actually exits — preserving the "CTS not disposed while `Task.Delay` holds the token" ordering the current comment (`:102-106`) protects.
Because the poll loop already treats `ObjectDisposedException` from a disposed CTS as normal cancellation (`:133-136`), the background-drain variant is safe: worst case the loop observes cancellation, the continuation disposes the CTS, no callback fires post-teardown (the loop's `while (!ct.IsCancellationRequested)` + `Task.Delay(ct)` exits before any further `_onChange`).
**Alternatives considered.**
- *Just shorten the 5 s timeout* — rejected: still blocks, just less; and a short timeout risks disposing the CTS while the loop runs.
- *Fire-and-forget cancel with no drain* — rejected: loses the "no callback after teardown" guarantee that the current design deliberately holds.
**Implementation steps.**
- `PollGroupEngine.cs`: add `public async ValueTask UnsubscribeAsync(ISubscriptionHandle handle)` doing `TryRemove` + `state.Cts.Cancel()` + `await (state.LoopTask ?? Task.CompletedTask).WaitAsync(5s)` (swallow) + `state.Cts.Dispose()`.
- Re-implement `Unsubscribe` to remove + cancel synchronously, then schedule `_ = DrainAndDisposeAsync(state)` (a private async method that awaits the loop then disposes the CTS) instead of blocking. Return `true` immediately.
- Audit the driver callers (Modbus/S7/AbCip/FOCAS subscription-management callbacks) — where the callback is `async`, prefer `await UnsubscribeAsync`. Grep `\.Unsubscribe(` across `src/Drivers`.
**Tests (Core.Abstractions.Tests).**
- `UnsubscribeAsync` completes promptly even when the reader delegate blocks (inject a reader that awaits a `TaskCompletionSource`); assert no `_onChange` fires after `UnsubscribeAsync` returns.
- Synchronous `Unsubscribe` returns without a multi-second stall (assert wall-clock under a small bound with a slow reader) and the CTS is eventually disposed (no leak) — the loop count drops to zero.
**Effort: M. Risk: medium** — teardown ordering is subtle; the existing `DisposeAsync` is the proven template to mirror. Blast radius: all poll-based drivers.
---
## 6. P-2 — Per-operation allocations on the authorization hot path (Medium)
**Verification: CONFIRMED.** `PermissionTrie.CollectMatches` (`PermissionTrie.cs:42`) does `ldapGroups.ToHashSet(OrdinalIgnoreCase)` per call, plus `new List<MatchedGrant>()` (`:45`). Called per node-operation by `TriePermissionEvaluator.Authorize` (verified consumer). A recursive browse / large CreateMonitoredItems batch = per-node garbage.
**Root cause.** The group set is rebuilt from the session's LDAP groups on every authorize even though `UserAuthorizationState.LdapGroups` is immutable per membership refresh.
**Proposed design.** Compute the case-insensitive `HashSet<string>` **once per session-version** and pass it in. Two clean options:
1. **Overload `CollectMatches(NodeScope, IReadOnlySet<string> groups)`** and have the evaluator hold a cached `HashSet` on `UserAuthorizationState` (or a small per-session wrapper) built when the membership/version changes. Keep the `IEnumerable` overload for callers that don't have a prebuilt set.
2. Cache the set on `UserAuthorizationState` itself (lazy, `Lazy<IReadOnlySet<string>>` keyed by the version field).
Prefer (1): it keeps `PermissionTrie` allocation-free on the hot path and puts the cache where the immutability actually lives (the session state). The `List<MatchedGrant>` is small and bounded by trie depth (≤ 6 levels × grants); leave it unless profiling flags it — or return via a pooled/stackalloc'd span if the evaluator only needs the OR-ed bitmask (it does: the caller "OR-s the flag bits"). A tighter design: add `CollectMask(scope, groups) -> NodePermissions` that OR-s inline and allocates nothing, keeping `CollectMatches` for diagnostics.
**Implementation steps.**
- `Core/Authorization/UserAuthorizationState.cs`: add a cached `IReadOnlySet<string> LdapGroupSet` built once per version (verify the version/refresh field name).
- `PermissionTrie.cs`: add `CollectMatches(NodeScope, IReadOnlySet<string>)` overload; optionally add `NodePermissions CollectMask(NodeScope, IReadOnlySet<string>)` that avoids the `List`.
- `TriePermissionEvaluator.Authorize`: pass the cached set / call `CollectMask`.
**Tests.** Existing authorization tests must pass unchanged (behavior identical). Add a test asserting the same grants resolve via the new set overload as the enumerable overload. A micro-bench is optional (no bench harness in repo).
**Effort: S. Risk: low** — additive overload; existing path preserved.
---
## 7. P-3 — PollGroupEngine: task-per-subscription, no cross-subscription batching (Medium)
**Verification: CONFIRMED.** `Subscribe` (`:73-84`) spawns a dedicated `Task.Run(PollLoopAsync)` per subscription with its own interval timer. No interval-bucketed scheduler or read coalescing.
**Root cause.** The engine was extracted from ModbusDriver as a per-subscription loop; fine at dozens of subscriptions, a scaling wall at hundreds/driver-instance.
**Proposed design.** This is a **structural fix disproportionate to current need** — the report itself says "Worth a note in the driver-facing docs at minimum." Recommend:
- **v1 (this pass): documentation only.** Add a remarks paragraph to `PollGroupEngine`'s XML doc stating the task-per-subscription cost model and the scaling ceiling, and note it in the driver-authoring doc (`docs/` driver guide) so integrators map poll groups, not per-tag subscriptions.
- **v2 (tracked follow-on, only if tag counts grow):** an interval-bucketed shared scheduler — one loop per distinct clamped interval, reading the union of that bucket's references once and fanning results to each subscription's diff state. This is the real fix and pairs naturally with OVERALL theme #2's "treat PollGroupEngine as the single home for fleet fixes."
**Implementation steps (v1).** Doc-only edit to `PollGroupEngine.cs` remarks + driver-authoring doc. No code/test change.
**Effort: S now (doc), L later (bucketed scheduler). Risk: none (doc); high-ish later (touches every poll driver's timing).**
---
## 8. C-2 — Core depends on Configuration; EF entities are the domain model (Medium)
**Verification: CONFIRMED.** `Core.csproj` references `Configuration` (`:16`). `PermissionTrie.cs:1` consumes `Configuration.Enums`; `EquipmentNodeWalker` consumes `Configuration.Entities`; composer plan records project EF entities.
**Root cause / posture.** Deliberate, consistent choice — persistence types *are* the model; plan records decouple the applier. The report flags this as **accepted-risk to document, not refactor.**
**Proposed design.** No refactor. Add an ADR / architecture-note entry documenting: (a) Configuration is the domain model by design; (b) the ripple cost (EF entity changes reach authorization + composition); (c) the mitigation guidance already in practice ("new code keeps preferring the plan-record boundary the composer established"). Cross-link from `CLAUDE.md`'s Architecture Overview.
**Implementation steps.** Doc-only: add to `docs/` (architecture/decisions) + a one-line note in the composer/PermissionTrie XML docs pointing at it.
**Effort: S. Risk: none.**
---
## 9. U-1 — Dead/dormant code retained in Core (Medium)
**Verification: CONFIRMED — all three are test-only:**
- `GenericDriverNodeManager` — only `tests/Core/.../GenericDriverNodeManagerTests.cs` references it (grep: zero production refs).
- `EquipmentNodeWalker` (+ `IdentificationFolderBuilder`) — only `tests/Core/.../OpcUa/EquipmentNodeWalkerTests.cs` + doc-comment mentions; self-declares "retained for unit-test support only."
- `EquipmentScriptPaths.TryParseRelayBody` (`Commons/Types/EquipmentScriptPaths.cs:164`) — only `Commons.Tests/EquipmentScriptPathsTests.cs` calls it (relay→alias converter deleted 2026-06-12).
**Root cause.** Code retained after its production caller was removed (Phase7→AddressSpace rename retired the walker; Galaxy-standard-driver retired the relay parser; GDNM superseded by composer→applier→sink).
**Proposed design.** **Delete all three + their test files** (git preserves history). This is OVERALL cross-cutting theme #1's remediation ("dormant code with green tests is worse than deleted code"). Order the deletion to also close **S-5** and part of **C-1**:
- Deleting `EquipmentNodeWalker` removes one of C-1's four `ExtractFullName` copies for free.
- Deleting `GenericDriverNodeManager` makes **S-5** (its non-synchronized re-walk teardown) moot — no separate fix needed.
**Implementation steps.**
1. Delete `src/Core/.../OpcUa/GenericDriverNodeManager.cs` + `tests/.../GenericDriverNodeManagerTests.cs`.
2. Delete `src/Core/.../OpcUa/EquipmentNodeWalker.cs` (+ `IdentificationFolderBuilder` if co-located and unused) + `tests/.../OpcUa/EquipmentNodeWalkerTests.cs`. Update the doc-comment references in `DeploymentArtifact.cs:666`, `AddressSpaceComposer.cs:287,530`, `OpcUaClientTagConfigModel.cs:11`, `IScriptTagCatalog.cs:56,182`, `OtOpcUaNodeManager.cs:34` to point at `TagConfigIntent.Parse` (they currently cite `EquipmentNodeWalker.ExtractFullName` as the canonical parser — that citation moves to Commons under C-1).
3. Delete `TryParseRelayBody` from `EquipmentScriptPaths.cs:164-172` + its two test cases in `EquipmentScriptPathsTests.cs:215-236`.
4. Verify the `Core/OpcUa/` folder is empty afterward; if so, remove it.
**Tests.** Removal only — the deleted tests go with the code. Full `dotnet build` + `dotnet test` to confirm no lingering references.
**Effort: S. Risk: low** (pure deletion; the compiler proves no production caller). Coordinate ordering with C-1 so the doc-comment repointing is consistent.
---
## 10. U-2 — Tier C recycle machinery has no `IDriverSupervisor` implementation (Medium)
**Verification: CONFIRMED.** `IDriverSupervisor` refs are exactly: the interface (`Core.Abstractions/IDriverSupervisor.cs`), two Stability consumers (`MemoryRecycle.cs`, `ScheduledRecycleScheduler.cs`), and their tests. **Zero concrete implementations.** Out-of-process drivers moved to external mxaccessgw/HistorianGateway sidecars, so no in-repo driver is Tier C — the recycle path is compiled, tested, unreachable.
**Root cause.** Speculative infrastructure for a tier-migration workflow that the external-gateway architecture superseded.
**Proposed design.** Two defensible options; recommend **prune** consistent with U-1's philosophy and OVERALL Action #12 ("delete `MemoryRecycle`/`IDriverSupervisor` dead machinery"):
- **Option A (recommended): delete** `MemoryRecycle`, `ScheduledRecycleScheduler`, `IDriverSupervisor`, `DriverResilienceStatusTracker.RecordRecycle` (verify it has no other caller first), and their tests. Removes a fully-built-but-unreachable subsystem.
- **Option B (if there's a real near-term tier-migration plan): keep + document** with an explicit "speculative — no production caller today; wired when in-process Tier C returns" note on each class, plus a tracking issue. The report leans toward pruning ("prune it alongside U-1").
**Decision input needed:** confirm with the maintainer that no tier-migration workflow is imminent. Absent that, prune.
**Implementation steps (Option A).**
1. Grep `RecordRecycle` — if only Stability + tests, remove it from `DriverResilienceStatusTracker`.
2. Delete `MemoryRecycle.cs`, `ScheduledRecycleScheduler.cs`, `IDriverSupervisor.cs`, `WedgeDetector`? (No — `WedgeDetector` is demand-aware stall detection, separate; keep unless it also proves callerless — grep first).
3. Delete `MemoryRecycleTests.cs`, `ScheduledRecycleSchedulerTests.cs`.
4. Note: `MemoryTracking` (median-baseline breach) may feed `MemoryRecycle` — verify `MemoryTracking` has an independent purpose (it likely feeds meters/health) before keeping; keep it if it has other consumers.
This also closes **S-4** (the ScheduledRecycleScheduler catch-up storm) by deletion. If Option B is chosen instead, apply the S-4 fast-forward fix below.
**Effort: S. Risk: low** (deletion, compiler-verified). **Blocked on maintainer confirmation** of no imminent tier-migration.
---
## 11. U-3 — Continuous-historization initial ref set still empty at spawn (Medium — known-limitation debt)
**Verification: CONFIRMED (partially-done, as the report frames it).**
- The **delta feed is implemented**: `AddressSpaceApplier.FeedHistorizedRefs` (`:310-353`) → `IHistorizedTagSubscriptionSink.UpdateHistorizedRefs``ActorHistorizedTagSubscriptionSink``ContinuousHistorizationRecorder.UpdateHistorizedRefs` (all present).
- The **initial set is still empty**: `Runtime/ServiceCollectionExtensions.cs:272` spawns the recorder with `historizedRefs: Array.Empty<string>()`, with a comment acknowledging it. So after a **process restart** with no subsequent deploy, the recorder historizes nothing (CLAUDE.md KNOWN LIMITATION 2). **Note:** OVERALL theme #5 flags KNOWN LIMITATION 2's *first half* (delta feed) as stale-in-docs but the *restart-convergence* gap this finding names is real.
**Root cause.** The deployed address space (and thus the historized-ref set) is materialised at deploy time, not at actor-spawn time, so there's no clean set to resolve at wiring time. Only *subsequent* deploys feed deltas.
**Proposed design.** **Full-set replay on rebuild.** The convergent fix is: whenever the applier does a full `RebuildCalled` rebuild (which re-materialises the entire composition), feed the recorder the **complete current historized-ref set** (an absolute "set to exactly this" message), not just the diff. On a fresh process, the first deploy/rebuild after boot then converges the recorder to the full set even with no prior state; on steady-state edits the delta feed continues to apply.
Design detail — add a `SetHistorizedRefs(IReadOnlyList<HistorizedTagRef> full)` absolute-set operation to `IHistorizedTagSubscriptionSink` (the seam contract already supports the fix per the report), and have `AddressSpaceApplier.Apply` call it (instead of / in addition to the delta) **when `rebuilt == true`**, computing the full historized set from `plan`... but `plan` is a *diff*, not the full composition. So the full set must come from the **composition**, which the applier's `Materialise*` methods receive (`MaterialiseEquipmentTags(composition)`). Cleanest: after the rebuild's materialise passes in `OpcUaPublishActor` (`:349`), compute the full historized-ref set from `composition.EquipmentTags` (filter `IsHistorized && Alarm is null`, resolve override-or-FullName exactly as `HistorizedRef` does) and call `SetHistorizedRefs`. Recorder's `OnUpdateHistorizedRefs` already "holds the full set and re-registers it" — add an `OnSetHistorizedRefs` that replaces the set wholesale.
**Alternatives considered.**
- *Feed the full set from `Apply` using the plan* — rejected: the plan is a diff; a fresh boot's first deploy against `_lastApplied = null` would produce an all-adds plan that *happens* to equal the full set, but that's fragile and breaks for a restart mid-stream where `_lastApplied` was restored. Deriving from `composition` is robust.
- *Persist the recorder's set across restarts* — larger; the FasterLog outbox is for values, not interest. Rebuild-replay is simpler and already aligned with the deploy lifecycle.
**Implementation steps.**
1. `Core.Abstractions/Historian/IHistorizedTagSubscriptionSink.cs`: add `void SetHistorizedRefs(IReadOnlyList<HistorizedTagRef> full)`. Update `NullHistorizedTagSubscriptionSink` no-op.
2. `Runtime/Historian/ActorHistorizedTagSubscriptionSink.cs`: forward to a new `ContinuousHistorizationRecorder.SetHistorizedRefs` message.
3. `ContinuousHistorizationRecorder.cs`: `Receive<SetHistorizedRefs>(OnSetHistorizedRefs)` — replace `_historizedRefs` wholesale and re-register dependency-mux interest.
4. `OpcUaPublishActor.cs` (after `:354`, when a rebuild ran): derive the full historized set from `composition` and call the sink's `SetHistorizedRefs`. (Or add an applier method `FeedFullHistorizedRefs(composition)` symmetric with `FeedHistorizedRefs(plan)`.)
5. Update CLAUDE.md KNOWN LIMITATION 2 to reflect the closed restart-convergence gap (coordinate with OVERALL Action #11).
**Tests.**
- `Runtime.Tests`: recorder converges to the full set on `SetHistorizedRefs` (registers interest in exactly the historized tags), and a fresh-spawn → rebuild → `SetHistorizedRefs` path historizes the full set.
- Restart-convergence integration test (the one OVERALL theme #5 says the closure "deserves"): spawn recorder empty, apply a composition with historized tags, assert values flow without a prior delta.
**Effort: M. Risk: medium** — touches the historization actor + the seam contract; live value-capture is still gated by the separate ContinuousHistorization live-validation (CLAUDE.md), so verify offline via unit/actor tests plus the env-gated live suite when a gateway is reachable.
---
## 12. P-4 — Per-write allocation in the non-idempotent write path (Low)
**Verification: CONFIRMED.** `CapabilityInvoker.ExecuteWriteAsync` (`:135-152`) builds `noRetryOptions = snapshot with { CapabilityPolicies = new Dictionary<...>{...} }` per non-idempotent write, then `GetOrCreate(id, "{host}::non-idempotent", Write, noRetryOptions)` — and `GetOrCreate` keys only on `(id, host, capability)`, ignoring the options after first build (comment cites the "1% pipeline budget").
**Root cause.** The no-retry options snapshot is rebuilt every call even though the resolved pipeline is cached and the options are only consumed on first build.
**Proposed design.** Since the cache key already ignores the options after first build, the allocation is pure waste on the hot path. Fastest fix: **only build `noRetryOptions` on a cache miss.** Add a `GetOrCreate` overload that takes an options *factory* (`Func<DriverResilienceOptions>`) invoked lazily only when the pipeline isn't cached. Then `ExecuteWriteAsync` passes a factory closing over `snapshot`; the closure (small) is built but the record+Dictionary only allocate on miss.
**Alternatives considered.**
- *Cache the no-retry pipeline separately keyed on options generation* — more state; the factory-on-miss approach reuses the existing cache and is minimal.
- *Precompute a per-host non-idempotent pipeline at options-change time* — most efficient but requires an options-change hook; overkill for a Low.
**Implementation steps.**
- `DriverResiliencePipelineBuilder.cs`: add `GetOrCreate(id, host, capability, Func<DriverResilienceOptions> optionsFactory)` overload that only calls the factory on a `ConcurrentDictionary` miss (use `GetOrAdd` with the factory).
- `CapabilityInvoker.ExecuteWriteAsync`: still snapshot `_optionsAccessor()` once (needed for correctness per the existing comment), but move the `with { ... }` construction into the factory lambda so it only runs on miss. (Minor: the snapshot itself is cheap; the Dictionary is the cost.)
**Tests.** `Core.Tests` resilience: assert a non-idempotent write on a warm cache does not rebuild options (spy the factory invocation count == 0 on the second call), and retries are still disabled.
**Effort: S. Risk: low.**
---
## 13. S-3 — `Subscribe`/`RegisterAsync` have no disposed guard (Low)
**Verification: CONFIRMED.**
- `PollGroupEngine.Subscribe` (`:73-84`) inserts into `_subscriptions` with no `_disposed` check; `DisposeAsync` (`:213-236`) snapshots `.Values` then clears — a `Subscribe` winning the race after the snapshot leaks a live loop.
- `DriverHost.RegisterAsync` (`:53-71`) vs `DisposeAsync` (`:92-106`): a registration after the snapshot+clear is initialized but never shut down.
**Root cause.** Shutdown-window races; no disposed flag.
**Proposed design.** Add a `volatile bool _disposed` to each, set at the top of `DisposeAsync` (inside the lock for `DriverHost`), and check it before insert:
- `PollGroupEngine.Subscribe`: if `_disposed`, either throw `ObjectDisposedException` (strict) or return a no-op handle. Given callers may race legitimately during teardown, prefer throwing `ObjectDisposedException` (the SDK convention) so callers don't silently believe they subscribed.
- `DriverHost.RegisterAsync`: check `_disposed` inside the existing `_lock` before adding; throw `ObjectDisposedException` if set.
Note `DisposeAsync` for `PollGroupEngine` isn't lock-guarded (it's `ConcurrentDictionary`-based); set `_disposed = true` first, then the snapshot loop, and have `Subscribe` check `_disposed` *after* `Interlocked.Increment` but *before* `_subscriptions[id] = state` — and if disposed-after-insert, remove+stop. Simplest robust form: check `_disposed` before starting the loop; if a dispose is concurrent, the double-check pattern (insert, then re-check `_disposed`, and if set, `TryRemove` + cancel) closes the window.
**Implementation steps.**
- `PollGroupEngine.cs`: add `_disposed`; guard `Subscribe` with the insert-then-recheck pattern; set flag in `DisposeAsync`.
- `DriverHost.cs`: add `_disposed`; guard `RegisterAsync` inside `_lock`; set flag in `DisposeAsync` inside `_lock`.
**Tests (Core.Tests / Core.Abstractions.Tests).**
- `Subscribe` after `DisposeAsync` throws `ObjectDisposedException` and leaves no live loop (assert `ActiveSubscriptionCount == 0`).
- `RegisterAsync` after `DisposeAsync` throws and doesn't leave an initialized-but-unshut driver.
**Effort: S. Risk: low.**
---
## 14. S-4 — ScheduledRecycleScheduler catch-up recycle storm after a stall (Low)
**Verification: CONFIRMED.** `TickAsync` (`:72-84`) advances `_nextRecycleUtc += _recycleInterval` (`:82`) by exactly one interval per fire. K missed intervals → K back-to-back recycles.
**Root cause.** Fixed-increment scheduling with no fast-forward past `utcNow`.
**Proposed design.** **Depends on U-2's decision.** If U-2 prunes this subsystem (recommended), S-4 disappears with it — no work. If U-2 keeps it, apply the report's fix: after a fire, fast-forward `_nextRecycleUtc` past `utcNow` in whole intervals:
```csharp
// after RecycleAsync:
do { _nextRecycleUtc += _recycleInterval; } while (_nextRecycleUtc <= utcNow);
```
(or compute the number of elapsed intervals arithmetically). Fires exactly once for any K-interval gap.
**Tests.** `ScheduledRecycleSchedulerTests`: feed a `utcNow` K intervals past `_nextRecycleUtc`; assert `RecycleAsync` called exactly once and `NextRecycleUtc > utcNow`.
**Effort: S. Risk: low. Gated on U-2 (likely deleted).**
---
## 15. S-5 — GDNM re-walk teardown not synchronized (Low — moot)
**Verification: CONFIRMED but moot.** `GenericDriverNodeManager.BuildAddressSpaceAsync` (`:58-71`) unsubscribes `_alarmForwarder` then `_alarmSinks.Clear()` non-atomically; a concurrent alarm event in the window is dropped. **But GDNM has no production caller (U-1).**
**Proposed design.** **Fold into U-1 — delete GDNM.** No standalone fix. If (against the recommendation) GDNM is kept, guard the teardown+re-subscribe under a lock.
**Effort: 0 (subsumed by U-1).**
---
## 16. C-4 — Composer violates its own purity contract with `Trace.TraceWarning` (Low)
**Verification: CONFIRMED.** Class doc says "Same inputs → same outputs, no logging" (`AddressSpaceComposer.cs:281`); the dangling-predicate-script skip emits `Trace.TraceWarning` (`:493-496`) — the only `System.Diagnostics.Trace` use in a Serilog codebase, invisible in production sinks.
**Root cause.** A warning was needed at a skip site in a static pure method with no logger; `Trace` was the path of least resistance.
**Proposed design.** **Return skipped-alarm ids in the composition** for the caller (which has an `ILogger`) to log. Add `IReadOnlyList<string> SkippedAlarmIds` (or a richer `SkippedAlarm(id, equipmentId, predicateScriptId)` record list) to `AddressSpaceComposition`; the composer collects skips instead of tracing; `OpcUaPublishActor` (which already logs the apply outcome) logs them at `Warning`. This preserves purity (same inputs → same outputs, including the skip list) and makes the warning visible.
**Alternatives considered.**
- *Inject an `ILogger`* — rejected: breaks the pure-static contract the byte-parity design leans on (and the artifact-decode mirror stays logger-free).
- *Just delete the trace* — rejected: loses a genuine operator signal (a dangling predicate reference that the draft validator *should* have caught but is the last line of defense).
**Implementation steps.**
- `AddressSpaceComposition`: add `SkippedAlarmIds` init-only member (default empty).
- `AddressSpaceComposer.cs:486-516`: collect skips into a list, drop the `Trace.TraceWarning`, set the member on return.
- `OpcUaPublishActor`: after `ParseComposition`/compose, if `SkippedAlarmIds` non-empty, `_log.Warning(...)`. (Note: the actor consumes `DeploymentArtifact.ParseComposition`, not the composer directly — so the artifact-decode mirror must also carry/emit the skip list, or the skip must be surfaced at compose time on the publish side. Verify which seam the runtime uses and thread the skip list through it.)
- Fix the class doc if any residual logging remains (it won't).
**Tests.** `OpcUaServer.Tests`: compose with a scripted alarm whose predicate script is absent → assert the alarm is skipped AND its id appears in `SkippedAlarmIds`.
**Effort: S. Risk: low.**
---
## 17. C-5 — Inconsistent duplicate-key defensiveness in Compose (Low)
**Verification: CONFIRMED.** `deviceHostById` uses a deliberate last-wins `foreach` with a comment (`AddressSpaceComposer.cs:359-364`), but `driversById`/`namespacesById` (`:401-402`) and `scriptsById` (`:453`) use plain `ToDictionary` — a duplicate logical id (only on DB-constraint bypass) throws and crashes the whole compose.
**Root cause.** Copy-paste divergence; the defensive posture wasn't applied uniformly.
**Proposed design.** **Adopt the defensive posture uniformly** (the report: "the defensive one is already argued for"). Replace the three `ToDictionary` calls with last-wins `foreach` builds (or a shared `static Dictionary<string,T> LastWins<T>(IEnumerable<T>, Func<T,string>)` helper in the composer). Rationale: a DB-constraint bypass should degrade gracefully (last-wins, matching the decode side) rather than crash the whole compose — consistent with the byte-parity contract the `deviceHostById` comment already defends.
**Alternatives considered.** *Make `deviceHostById` throw too (fail-fast everywhere)* — rejected: diverges from the artifact-decode side's last-wins, breaking byte-parity, which the existing comment explicitly warns against.
**Implementation steps.**
- Add a private `LastWins` helper; replace `driversById`, `namespacesById`, `scriptsById` construction. Confirm the decode side (`DeploymentArtifact`) uses last-wins for the same maps and matches.
**Tests.** `OpcUaServer.Tests`: compose with a duplicate `DriverInstanceId` (or `ScriptId`) → assert no throw and last-wins selection, matching decode.
**Effort: S. Risk: low.**
---
## 18. C-6 — Vestigial Akka package reference in Commons (Low)
**Verification: CONFIRMED.** `Commons.csproj:9` references `Akka`; the only "Akka" token in Commons source is a **doc comment** in `NodeDiagnosticsSnapshot.cs:15` ("over Akka"). No Akka type is used.
**Root cause.** Leftover from an earlier design; harmless today (every consumer is Akka-hosted) but contradicts Commons' dependency-light contracts role.
**Proposed design.** **Remove the `<PackageReference Include="Akka"/>` from `Commons.csproj`.** Build to confirm nothing breaks (grep already proves no type usage).
**Implementation steps.** Delete the line; `dotnet restore`/`build`. If CPM (`Directory.Packages.props`) versions it, leave the version entry (other projects may use it) but drop the Commons reference.
**Tests.** Build + full test run.
**Effort: S. Risk: low.**
---
## 19. C-7 — Stale scaffold-era XML docs on load-bearing classes (Low)
**Verification: CONFIRMED.**
- `AddressSpaceApplier.cs:7-26` class doc still describes the F14 scaffold ("For now we record the work", "the SDK adapter that lands in F10b will decide…") though both shipped (the class *does* the surgical-vs-rebuild decision now).
- `DriverHost.cs:5-10` doc promises "per-process isolation for Tier C … implemented in Phase 2 via named-pipe RPC" — superseded by the out-of-repo mxaccessgw gateway.
**Root cause.** Docs not updated when the features shipped / architecture changed.
**Proposed design.** Rewrite both class docs to describe current reality. For `AddressSpaceApplier`: describe the actual full-rebuild-vs-surgical decision, the two detached hooks, and the SDK-free sink boundary. For `DriverHost`: describe it as the in-process id→`IDriver` lifecycle registry; drop the named-pipe/Tier C narrative (or add a one-line "out-of-process drivers now live in external gateways" pointer). Coordinate with the `fixdocs`/`checkdocs` skill conventions already used in this repo (recent commit `9cad9ed0` was an XML-doc sweep).
**Implementation steps.** Edit the two class-doc blocks. While there, if U-2 keeps `DriverHost`'s Tier C references anywhere, align them.
**Tests.** None (doc). Optionally run `checkdocs`/CommentChecker.
**Effort: S. Risk: none.**
---
## 20. U-4 — Additive-only ACL model (no Deny) — documented v2.0 gap (Low)
**Verification: CONFIRMED.** `PermissionTrie.cs:13-17` explicitly states "pure union (additive grants; no explicit Deny in v2.0)." A broad cluster-root grant can't be carved back at a sub-scope.
**Root cause.** Deliberate v2.0 simplification aligned with flat, global AdminUI roles.
**Proposed design.** **No code change — document the boundary in the security docs.** Add a "Authorization model: additive-only, no Deny" subsection to `docs/security.md` stating the bound (fine-grained OT authz needs a trie-walk semantics change to add Deny/most-specific-wins) so the limitation is visible to deployment planners. If/when Deny is needed, it's a scoped follow-on: add a `Deny` flag dimension to `TrieGrant` and change `CollectMatches` from pure-OR to a precedence walk (most-specific-scope Deny wins) — out of scope here.
**Implementation steps.** Doc-only edit to `docs/security.md`.
**Effort: S. Risk: none.**
---
## 21. U-5 — Uneven test coverage; ClusterRoleInfo untested (Low)
**Verification: CONFIRMED.** `Cluster.Tests` contains only `RoleParserTests`, `HoconLoaderTests`, `ServiceLevelCalculatorTests` (the three pure classes). `ClusterRoleInfo` — the only concurrency-bearing class in Cluster (lock + subscriber actor + event fan-out) — has **no dedicated unit test** (it appears only in Server-level integration harnesses: `RedundancyStateActorTests`, `MultiClusterScopingTests`, `TwoNodeClusterHarness`, `ServiceCollectionExtensionsTests`). The applier failure surface (S-1) and `DriverHost`/`PollGroupEngine` dispose races (S-3) are untested because those surfaces don't exist yet.
**Root cause.** Coverage concentrated on pure/deterministic classes; the concurrency-bearing seam and fault surfaces were left to live-verification / integration.
**Proposed design.** Add an **Akka TestKit harness for `ClusterRoleInfo`** exercising leader-change sequencing — the report's "riskiest untested seam." Cover: role topology snapshot updates under the lock, event fan-out on membership/leader change, and no-lost-update under concurrent snapshot reads during a leader change. The S-1 and S-3 tests are already specified under their own findings (they land as those surfaces are built), which is the bulk of closing U-5's named gaps.
**Implementation steps.**
- `tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ClusterRoleInfoTests.cs` using Akka.TestKit (verify TestKit is already a test dep — `Host.IntegrationTests` uses cluster harnesses, so it's available): drive a fake cluster-event stream through the subscriber actor and assert `ClusterRoleInfo`'s snapshot + fan-out.
**Tests.** This finding *is* a test-addition; no product code changes.
**Effort: M. Risk: low** (test-only; the risk is flaky async assertions — use TestKit's deterministic probes).
---
## Cross-finding sequencing (recommended PR order)
1. **PR A (C-1 + P-1 + C-3):** land `TagConfigIntent.Parse` + `DeviceConfigIntent` in Commons; add `Configuration → Commons` reference; retrofit composer, DeploymentArtifact, DraftValidator; add shared driver-type constant + `EquipmentNodeIds`-keyed collision check. Behind the byte-parity test suite. *Highest-leverage; unblocks C-3 and the U-1 doc-repointing.*
2. **PR B (U-1 + S-5):** delete GDNM, EquipmentNodeWalker, TryParseRelayBody + tests; repoint doc comments at Commons parser (depends on PR A).
3. **PR C (S-1):** applier failure surface + publish-actor signal.
4. **PR D (U-2 decision):** prune Tier C recycle (closes S-4) — *gated on maintainer confirmation*; else apply S-4 fast-forward.
5. **PR E (U-3):** full-set replay on rebuild + CLAUDE.md KNOWN LIMITATION 2 update.
6. **PR F (S-2 + S-3):** PollGroupEngine `UnsubscribeAsync` + disposed guards + DriverHost guard.
7. **PR G (P-2 + P-4):** authorization set caching + non-idempotent-write factory-on-miss.
8. **PR H (Lows/docs):** C-4, C-5, C-6, C-7, C-2 note, U-4 doc, P-3 doc, U-5 ClusterRoleInfo tests.
---
## Notes for the executor
- **Byte-parity is the crown jewel.** Any change to composer/DeploymentArtifact/DraftValidator parsing (C-1/C-3/C-5) must keep the existing cross-seam parity test green; port semantics verbatim, no behavior change intended.
- **`Commons` is a verified pure leaf** — moving shared code there is cycle-safe and the correct home per the `EquipmentScriptPaths` precedent.
- **Deletions (U-1/U-2) are compiler-verified** — no production caller exists for GDNM, EquipmentNodeWalker, TryParseRelayBody, or any `IDriverSupervisor` impl. Git preserves history.
- **U-2 and the U-2-dependent S-4 need one maintainer decision** (prune vs. keep-and-document the Tier C recycle machinery). Everything else is unblocked.
- Live-verify surfaces in this domain are thin (compose/authorize/teardown are unit-testable); the only item needing a real gateway is U-3's continuous-historization value capture, which stays behind the existing env-gated live suite.