Add the 7 per-domain design+implementation plans (archreview/plans/) with an index, produced from the 2026-07-08 architecture review. Fix two confirmed doc drifts the review flagged (theme #5): - CLAUDE.md KNOWN LIMITATION 2: the continuous-historization historized-ref feed IS wired (AddressSpaceApplier.FeedHistorizedRefs -> UpdateHistorizedRefs -> recorder); rewrite to reflect that value-capture is code-complete and only the live end-to-end + restart-convergence verification remains. - CLAUDE.md ScriptAnalysis gating: endpoints use Roles=Administrator,Designer via RequireAuthorization, not the FleetAdmin policy.
50 KiB
Design + Implementation Plan — Core & Composition Pipeline
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 ProjectReferences,
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 onlyRemovedNodes/AddedNodes/ChangedNodes/RebuildCalled— no failure count.SafeRebuild(:373-383) catches every exception from_sink.RebuildAddressSpace()and only logs; the caller still setsrebuilt = true(:150-153).SafeEnsureFolder/SafeEnsureVariable(:612-622),SafeWriteAlarmCondition/SafeMaterialiseAlarmCondition(:677-687) swallow per-node failures into Warnings.- Consumer confirmed:
OpcUaPublishActor.cs:335calls_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 noApplyAck/DeploymentFailedreply 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:
- Count failures in the outcome. Change the
Safe*helpers to return abool(or increment a private counter) and addRebuildFailed+FailedNodestoAddressSpaceApplyOutcome.SafeRebuildreturning false must fliprebuilt's meaning: keepRebuildCalled(it was attempted) but setRebuildFailed = trueso callers can distinguish "rebuilt OK" from "rebuild threw". - Surface it. In
OpcUaPublishActor, whenoutcome.RebuildFailed || outcome.FailedNodes > 0, log atError(not Info) and emit an operator-visible signal. Reuse the existing telemetry meterOtOpcUaTelemetry.OpcUaSinkWritewith akind:"apply-failed"tag (pattern already used atOpcUaPublishActor.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 "escalateSafeRebuildfailure 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 (:80empty-plan,:215main return). SafeRebuild()→private bool SafeRebuild()returningfalseon catch; caller recordsrebuildFailed.SafeEnsureFolder/SafeEnsureVariable/SafeWriteAlarmCondition/SafeMaterialiseAlarmCondition→ returnbool; theMaterialise*passes tally a_failedNodescounter. Because theMaterialise*passes run fromOpcUaPublishActorafterApplyreturns (they are separate public methods,:338-354), expose the tally via either (a) an out-param/return on eachMaterialise*method, or (b) an instance counter reset at the start of the rebuild sequence and read after. Prefer (a) — eachMaterialise*returns anint failedNodes; the actor sums them.
- Extend record:
OpcUaPublishActor.cs(:335-358): capture the failure tally acrossApply+ the fourMaterialise*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
NullOpcUaAddressSpaceSinkpattern extended to throw onRebuildAddressSpace/EnsureVariable): assertoutcome.RebuildFailed == truewhen rebuild throws, andFailedNodes == Nwhen NEnsureVariablecalls 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:666citesEquipmentNodeWalker.ExtractFullName).Core/OpcUa/EquipmentNodeWalker.csExtractFullName(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).
// 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.
- Add
Commons/Types/TagConfigIntent.cs(+DeviceConfigIntenthelper). Port the exact current parse semantics verbatim fromAddressSpaceComposer(the canonical copy) — especially the raw-blob fallback forFullName, thehistorizeToAvevabool? default-on rule, and thearrayLength-only-when-isArrayrule — so no byte-parity behavior changes. AddressSpaceComposer.cs: replace the per-tagExtractTagFullName/ExtractTagAlarm/ExtractTagHistorize/ExtractTagArraycalls (:418-433) with a singlevar intent = TagConfigIntent.Parse(t.TagConfig);then project fields offintent. ReplaceTryExtractDeviceHost/NormalizeDeviceHostusages with the Commons helper. Delete the now-private methods.Runtime/Drivers/DeploymentArtifact.cs: replace its mirrorExtract*methods withTagConfigIntent.Parse.Configuration/Validation/DraftValidator.cs: add aProjectReferenceto Commons (cycle-safe — verified), replaceExtractTagConfigFullName(:60-71) withTagConfigIntent.Parse(t.TagConfig).FullName(note: current DraftValidator returns null for the missing case; preserve that by checkingstring.IsNullOrWhiteSpaceagainst 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 aHasExplicitFullNamebool on the intent or keep DraftValidator's null-on-absent semantics by adding aTagConfigIntent.TryGetExplicitFullName).EquipmentNodeWalker.ExtractFullName: deleted with the walker under U-1 (or repointed at Commons if the walker survives as a test fixture).
Tests.
Commons.Tests: exhaustiveTagConfigIntent.Parsetable (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
AddressSpaceComposercompose output andDeploymentArtifactdecode 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 localKey(...)because Configuration can't referenceCommons.OpcUa.EquipmentNodeIds(Commons/OpcUa/EquipmentNodeIds.csconfirmed to exist).DraftValidator.cs:48hard-codesdtype != "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(...)inValidateNoEquipmentSignalNameCollisionwith the same keyEquipmentNodeIdsproduces (add a publicEquipmentNodeIds.Variable(eq, folder, name)— it already exists perAddressSpaceApplier.cs:177usage — and call it, so the collision check keys on the exact materialiser scheme, eliminating drift risk). - Replace
"GalaxyMxGateway"with a shared driver-type constant. AddDriverTypes.GalaxyMxGateway(aconst string) in Commons (or reuse an existing driver-type-names home if one exists — grepDriverTypeconstants first). Point DraftValidator at it.
Implementation steps.
- (Depends on C-1's
Configuration → Commonsreference.) - Ensure
EquipmentNodeIds.Variableis public and matches DraftValidator'snull-folder ⇒ "{eq}/{name}"behavior (verify againstAddressSpaceApplier.cs:177). If the VirtualTag branch (which passesfolder=null) needs the no-folder overload, add it. - Add
Commonsdriver-type constant; replace the literal atDraftValidator.cs:48. - Grep for other
"GalaxyMxGateway"literals (composer comment references it;DriverFactoryBootstrapregisters 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: addpublic async ValueTask UnsubscribeAsync(ISubscriptionHandle handle)doingTryRemove+state.Cts.Cancel()+await (state.LoopTask ?? Task.CompletedTask).WaitAsync(5s)(swallow) +state.Cts.Dispose().- Re-implement
Unsubscribeto remove + cancel synchronously, then schedule_ = DrainAndDisposeAsync(state)(a private async method that awaits the loop then disposes the CTS) instead of blocking. Returntrueimmediately. - Audit the driver callers (Modbus/S7/AbCip/FOCAS subscription-management callbacks) — where the callback is
async, preferawait UnsubscribeAsync. Grep\.Unsubscribe(acrosssrc/Drivers.
Tests (Core.Abstractions.Tests).
UnsubscribeAsynccompletes promptly even when the reader delegate blocks (inject a reader that awaits aTaskCompletionSource); assert no_onChangefires afterUnsubscribeAsyncreturns.- Synchronous
Unsubscribereturns 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:
- Overload
CollectMatches(NodeScope, IReadOnlySet<string> groups)and have the evaluator hold a cachedHashSetonUserAuthorizationState(or a small per-session wrapper) built when the membership/version changes. Keep theIEnumerableoverload for callers that don't have a prebuilt set. - Cache the set on
UserAuthorizationStateitself (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 cachedIReadOnlySet<string> LdapGroupSetbuilt once per version (verify the version/refresh field name).PermissionTrie.cs: addCollectMatches(NodeScope, IReadOnlySet<string>)overload; optionally addNodePermissions CollectMask(NodeScope, IReadOnlySet<string>)that avoids theList.TriePermissionEvaluator.Authorize: pass the cached set / callCollectMask.
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— onlytests/Core/.../GenericDriverNodeManagerTests.csreferences it (grep: zero production refs).EquipmentNodeWalker(+IdentificationFolderBuilder) — onlytests/Core/.../OpcUa/EquipmentNodeWalkerTests.cs+ doc-comment mentions; self-declares "retained for unit-test support only."EquipmentScriptPaths.TryParseRelayBody(Commons/Types/EquipmentScriptPaths.cs:164) — onlyCommons.Tests/EquipmentScriptPathsTests.cscalls 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
EquipmentNodeWalkerremoves one of C-1's fourExtractFullNamecopies for free. - Deleting
GenericDriverNodeManagermakes S-5 (its non-synchronized re-walk teardown) moot — no separate fix needed.
Implementation steps.
- Delete
src/Core/.../OpcUa/GenericDriverNodeManager.cs+tests/.../GenericDriverNodeManagerTests.cs. - Delete
src/Core/.../OpcUa/EquipmentNodeWalker.cs(+IdentificationFolderBuilderif co-located and unused) +tests/.../OpcUa/EquipmentNodeWalkerTests.cs. Update the doc-comment references inDeploymentArtifact.cs:666,AddressSpaceComposer.cs:287,530,OpcUaClientTagConfigModel.cs:11,IScriptTagCatalog.cs:56,182,OtOpcUaNodeManager.cs:34to point atTagConfigIntent.Parse(they currently citeEquipmentNodeWalker.ExtractFullNameas the canonical parser — that citation moves to Commons under C-1). - Delete
TryParseRelayBodyfromEquipmentScriptPaths.cs:164-172+ its two test cases inEquipmentScriptPathsTests.cs:215-236. - 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).
- Grep
RecordRecycle— if only Stability + tests, remove it fromDriverResilienceStatusTracker. - Delete
MemoryRecycle.cs,ScheduledRecycleScheduler.cs,IDriverSupervisor.cs,WedgeDetector? (No —WedgeDetectoris demand-aware stall detection, separate; keep unless it also proves callerless — grep first). - Delete
MemoryRecycleTests.cs,ScheduledRecycleSchedulerTests.cs. - Note:
MemoryTracking(median-baseline breach) may feedMemoryRecycle— verifyMemoryTrackinghas 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:272spawns the recorder withhistorizedRefs: 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
Applyusing the plan — rejected: the plan is a diff; a fresh boot's first deploy against_lastApplied = nullwould produce an all-adds plan that happens to equal the full set, but that's fragile and breaks for a restart mid-stream where_lastAppliedwas restored. Deriving fromcompositionis 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.
Core.Abstractions/Historian/IHistorizedTagSubscriptionSink.cs: addvoid SetHistorizedRefs(IReadOnlyList<HistorizedTagRef> full). UpdateNullHistorizedTagSubscriptionSinkno-op.Runtime/Historian/ActorHistorizedTagSubscriptionSink.cs: forward to a newContinuousHistorizationRecorder.SetHistorizedRefsmessage.ContinuousHistorizationRecorder.cs:Receive<SetHistorizedRefs>(OnSetHistorizedRefs)— replace_historizedRefswholesale and re-register dependency-mux interest.OpcUaPublishActor.cs(after:354, when a rebuild ran): derive the full historized set fromcompositionand call the sink'sSetHistorizedRefs. (Or add an applier methodFeedFullHistorizedRefs(composition)symmetric withFeedHistorizedRefs(plan).)- 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 onSetHistorizedRefs(registers interest in exactly the historized tags), and a fresh-spawn → rebuild →SetHistorizedRefspath 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: addGetOrCreate(id, host, capability, Func<DriverResilienceOptions> optionsFactory)overload that only calls the factory on aConcurrentDictionarymiss (useGetOrAddwith the factory).CapabilityInvoker.ExecuteWriteAsync: still snapshot_optionsAccessor()once (needed for correctness per the existing comment), but move thewith { ... }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_subscriptionswith no_disposedcheck;DisposeAsync(:213-236) snapshots.Valuesthen clears — aSubscribewinning the race after the snapshot leaks a live loop.DriverHost.RegisterAsync(:53-71) vsDisposeAsync(: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 throwObjectDisposedException(strict) or return a no-op handle. Given callers may race legitimately during teardown, prefer throwingObjectDisposedException(the SDK convention) so callers don't silently believe they subscribed.DriverHost.RegisterAsync: check_disposedinside the existing_lockbefore adding; throwObjectDisposedExceptionif 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; guardSubscribewith the insert-then-recheck pattern; set flag inDisposeAsync.DriverHost.cs: add_disposed; guardRegisterAsyncinside_lock; set flag inDisposeAsyncinside_lock.
Tests (Core.Tests / Core.Abstractions.Tests).
SubscribeafterDisposeAsyncthrowsObjectDisposedExceptionand leaves no live loop (assertActiveSubscriptionCount == 0).RegisterAsyncafterDisposeAsyncthrows 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:
// 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: addSkippedAlarmIdsinit-only member (default empty).AddressSpaceComposer.cs:486-516: collect skips into a list, drop theTrace.TraceWarning, set the member on return.OpcUaPublishActor: afterParseComposition/compose, ifSkippedAlarmIdsnon-empty,_log.Warning(...). (Note: the actor consumesDeploymentArtifact.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
LastWinshelper; replacedriversById,namespacesById,scriptsByIdconstruction. 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-26class 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-10doc 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.csusing Akka.TestKit (verify TestKit is already a test dep —Host.IntegrationTestsuses cluster harnesses, so it's available): drive a fake cluster-event stream through the subscriber actor and assertClusterRoleInfo'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)
- PR A (C-1 + P-1 + C-3): land
TagConfigIntent.Parse+DeviceConfigIntentin Commons; addConfiguration → Commonsreference; 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. - PR B (U-1 + S-5): delete GDNM, EquipmentNodeWalker, TryParseRelayBody + tests; repoint doc comments at Commons parser (depends on PR A).
- PR C (S-1): applier failure surface + publish-actor signal.
- PR D (U-2 decision): prune Tier C recycle (closes S-4) — gated on maintainer confirmation; else apply S-4 fast-forward.
- PR E (U-3): full-set replay on rebuild + CLAUDE.md KNOWN LIMITATION 2 update.
- PR F (S-2 + S-3): PollGroupEngine
UnsubscribeAsync+ disposed guards + DriverHost guard. - PR G (P-2 + P-4): authorization set caching + non-idempotent-write factory-on-miss.
- 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.
Commonsis a verified pure leaf — moving shared code there is cycle-safe and the correct home per theEquipmentScriptPathsprecedent.- Deletions (U-1/U-2) are compiler-verified — no production caller exists for GDNM, EquipmentNodeWalker, TryParseRelayBody, or any
IDriverSupervisorimpl. 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.