Files
lmxopcua/archreview/01-core-composition.md
T
Joseph Doherty 1891f5d6a7 docs(archreview): 2026-07-12 re-review at f6eaa267 + 12 round-2 remediation plans
Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.

Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
2026-07-12 23:52:23 -04:00

554 lines
44 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.
# Architecture Review — Core Composition Pipeline and Core Libraries
**Date:** 2026-07-12
**Commit:** `f6eaa267` (master, clean tree)
**Note:** This updates the 2026-07-08 review at `9cad9ed0`. Since that baseline the arch-review
remediation branches merged to master; the pieces landing in this domain are: the
`IDriverCapabilityInvoker`/`IDriverCapabilityInvokerFactory` seam in Core.Abstractions +
`DriverCapabilityInvokerFactory` in Core (`bacea1a4`, task #10), per-instance ResilienceConfig
read-path plumbing through the deploy artifact (`75403caa`, task #13), the reflection-exhaustive
deferred-sink forwarding guard in Commons.Tests (`a65c2ced`, task #6), the typed SBR option in
Cluster (`a81dea10` + premise correction `eaf78aad`), the OTOPCUA0001 analyzer wiring (`f0082af5`),
`IScriptCacheOwner` in Commons (`7fd44f0f`), and a fixdocs XML-doc sweep (`3b5ef439`).
**Reviewer scope:**
- `src/Core/ZB.MOM.WW.OtOpcUa.Core` (driver hosting, resilience, stability, authorization trie)
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` (driver capability interfaces, `PollGroupEngine`, historian seams, the new invoker seam)
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons` (cluster message contracts, deferred sinks, NodeId scheme, telemetry)
- `src/Core/ZB.MOM.WW.OtOpcUa.Configuration` (EF config persistence, generation-sealed local cache, draft validation)
- `src/Core/ZB.MOM.WW.OtOpcUa.Cluster` (Akka bootstrap, role info, ServiceLevel, SBR options)
- Plus the address-space composition pipeline itself — `AddressSpaceComposer` / `AddressSpacePlanner` / `AddressSpaceApplier`
**Scope note (location drift).** The review brief places the composition pipeline in
`ZB.MOM.WW.OtOpcUa.Core`, but since the Phase7→AddressSpace rename it lives in
`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/` (`AddressSpaceComposer.cs`, `AddressSpacePlan.cs`,
`AddressSpaceApplier.cs`), with its tests under `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/`.
The `Core` project's own `OpcUa/` folder still holds only the retired `EquipmentNodeWalker` and an
unused `GenericDriverNodeManager` (see U-1). The pipeline was reviewed in full regardless. The
composition pipeline itself received **zero code changes** in the 9cad9ed0→f6eaa267 window; the
domain's delta is concentrated in `Core/Resilience`, `Core.Abstractions`, `Cluster`, and the Runtime
dispatch layer that consumes the new seam.
---
## Prior-finding status (9cad9ed0 → f6eaa267)
Every prior finding was re-verified against the current code, not the STATUS.md claims.
| ID | One-line description | Verified status |
|---|---|---|
| S-1 | Applier swallows every sink failure; deploy reported applied even when broken | **STILL OPEN**`Safe*` helpers unchanged (`AddressSpaceApplier.cs:373383, 612622`); `AddressSpaceApplyOutcome` (`:691`) still has no failure fields; `rebuilt = true` still set after `SafeRebuild()` (`:148153`). STATUS.md itself lists 01/S-1 under "Suggested next". |
| S-2 | `PollGroupEngine.Unsubscribe` blocks up to 5 s per subscription | **STILL OPEN**`task.Wait(TimeSpan.FromSeconds(5))` at `PollGroupEngine.cs:109`. |
| S-3 | No disposed guard: `Subscribe`/`RegisterAsync` race `DisposeAsync` | **STILL OPEN** — no `_disposed` flag in `PollGroupEngine.cs` or `Core/Hosting/DriverHost.cs`. |
| S-4 | `ScheduledRecycleScheduler` catch-up recycle storm after a stall | **STILL OPEN**`_nextRecycleUtc += _recycleInterval` (`ScheduledRecycleScheduler.cs:82`), no fast-forward past `utcNow`. |
| S-5 | `GenericDriverNodeManager` re-walk teardown unsynchronized | **STILL OPEN (moot)** — code unchanged; the #10 triage added an OTOPCUA0001 pragma (`GenericDriverNodeManager.cs:69`) explicitly memorializing "zero production references", strengthening the U-1 delete case. |
| P-1 | `Compose` parses each tag's TagConfig JSON four times | **STILL OPEN** — four `JsonDocument.Parse` sites unchanged (`AddressSpaceComposer.cs:541, 606, 638, 670` from `:418433`). |
| P-2 | Per-operation allocations on the authorization hot path | **STILL OPEN**`ldapGroups.ToHashSet(...)` per call at `PermissionTrie.cs:42`. |
| P-3 | `PollGroupEngine`: task-per-subscription, no coalescing | **STILL OPEN** — engine unchanged. |
| P-4 | Per-write allocation in the non-idempotent write path | **STILL OPEN** — options record + `Dictionary` per call at `CapabilityInvoker.cs:117131` (and now production-unreachable — see S-8). |
| C-1 | Byte-parity TagConfig parsing replicated in four projects | **STILL OPEN** — all four copies + "MUST parse identically" comments intact; #13 added a fifth small parity-coupled read (`DeploymentArtifact.TryReadSpec` ResilienceConfig) though that one is a trivial string read. |
| C-2 | Core depends on Configuration; EF entities are the domain model | **STILL OPEN (accepted-risk)**`Core.csproj:16` still references Configuration. |
| C-3 | `DraftValidator` re-derives NodeId scheme + hard-codes `"GalaxyMxGateway"` | **STILL OPEN**`DraftValidator.cs:48, 65, 7597` unchanged. |
| C-4 | Composer violates purity contract with `Trace.TraceWarning` | **STILL OPEN**`AddressSpaceComposer.cs:493`. |
| C-5 | Inconsistent duplicate-key defensiveness in `Compose` | **STILL OPEN** — last-wins `deviceHostById` (`:357363`) vs throw-on-dupe `ToDictionary` (`:401402, 453`). |
| C-6 | Vestigial `Akka` package reference in Commons | **STILL OPEN**`Commons.csproj:9`. |
| C-7 | Stale scaffold-era XML docs on load-bearing classes | **PARTIALLY FIXED** — fixdocs `3b5ef439` converted `CapabilityInvoker`'s duplicated docs to `<inheritdoc/>`, but both flagged narratives survive verbatim: the applier's F14-scaffold doc ("For now we record the work", "the SDK adapter that lands in F10b will decide…" — `AddressSpaceApplier.cs:726`) and `DriverHost`'s "Phase 2 via named-pipe RPC" promise (`Core/Hosting/DriverHost.cs:510`). |
| U-1 | Dead/dormant code retained: `GenericDriverNodeManager`, `EquipmentNodeWalker`, `TryParseRelayBody` | **STILL OPEN** — all three present (`Core/OpcUa/` lists both classes + `IdentificationFolderBuilder`; `EquipmentScriptPaths.cs:164`). Evidence grew: the analyzer triage now documents GDNM as test-only scaffolding in a pragma. |
| U-2 | Tier C recycle machinery has no `IDriverSupervisor` implementation | **STILL OPEN** — repo-wide search still finds zero concrete implementations; the new `DriverResilienceOptionsParser` even validates `RecycleIntervalSeconds` as "Tier C only" for a tier that cannot execute (`DriverResilienceOptionsParser.cs:101110`). |
| U-3 | Continuous-historization initial interest set empty at spawn | **PARTIALLY FIXED / RE-ANALYSED** — the recorder is still spawned with `Array.Empty<string>()` (`Runtime/ServiceCollectionExtensions.cs:277`), but re-analysis shows restart convergence is mechanically present: `DriverHostActor.RestoreApplied` (`DriverHostActor.cs:12931320`) fires `RebuildAddressSpace` on bootstrap, `OpcUaPublishActor.HandleRebuild` diffs against an **empty** `_lastApplied` baseline → a full-add plan → `Apply``FeedHistorizedRefs` posts the complete set. The prior claim "historizes nothing until the next deploy" was too strong. What remains is exactly CLAUDE.md Known Limitation 2 (reworded by the remediation docs branch): no restart-convergence test and no live end-to-end verification. Kept in the body, downgraded. |
| U-4 | Additive-only ACL model (no Deny) | **STILL OPEN (documented v2.0 gap)**`PermissionTrie.cs:1317` unchanged. |
| U-5 | Test coverage strong but unevenly distributed | **PARTIALLY IMPROVED** — new suites landed: `SplitBrainResolverActivationTests` (Cluster.Tests, now 4 files), `DriverCapabilityInvokerFactoryTests` (148 lines), pipeline-builder logging tests, and the Commons `DeferredSinkForwardingReflectionTests` guard. But `ClusterRoleInfo` — the only concurrency-bearing Cluster class — still has zero tests, and the S-1/S-3 surfaces remain untestable because they don't exist. Kept in the body, narrowed. |
Net: **0 of the 5 stability findings, 0 of the 4 performance findings, and 6 of the 7 convention
findings are unchanged**; the remediation wave targeted other domains' Criticals and *landed new
code in this domain* rather than fixing this domain's backlog. That new code is reviewed below
(S-6/S-7/S-8, U-6, C-8).
---
## Architecture Overview
### Composition pipeline (deploy → address space)
The pipeline is a clean three-stage compose/diff/apply design (unchanged this window):
1. **`AddressSpaceComposer.Compose`** (`OpcUaServer/AddressSpaceComposer.cs:290`) — a pure static
projection from EF config entities (`UnsArea`, `UnsLine`, `Equipment`, `DriverInstance`, `Tag`,
`VirtualTag`, `Script`, `ScriptedAlarm`, `Device`, `Namespace`) into an
`AddressSpaceComposition`: sorted lists of `UnsAreaProjection`/`UnsLineProjection`/
`EquipmentNode`/`DriverInstancePlan`/`EquipmentTagPlan`/`EquipmentVirtualTagPlan`/
`EquipmentScriptedAlarmPlan`. Tag intent (driver `FullName`, native-alarm object, historize
flags, array shape) is parsed out of the schemaless `Tag.TagConfig` JSON here. VirtualTag
scripts get `{{equip}}` token substitution and dependency-ref extraction via the shared
`Commons.Types.EquipmentScriptPaths` helper. Everything is ordinally sorted so the composition
is deterministic — the foundation of the "byte-parity" contract with the artifact-decode mirror
in `Runtime/Drivers/DeploymentArtifact.cs`.
2. **`AddressSpacePlanner.Compute`** (`OpcUaServer/AddressSpacePlan.cs:96`) — a pure diff of two
compositions keyed on stable logical ids, using record value-equality as the changed check
(with hand-written `Equals`/`GetHashCode` on the plans carrying `IReadOnlyList` members so a
no-op redeploy diffs empty). Emits Added/Removed/Changed sets per entity class plus UNS
folder renames. `AddressSpacePlan.IsEmpty` is the short-circuit gate `OpcUaPublishActor` uses.
3. **`AddressSpaceApplier.Apply`** (`OpcUaServer/AddressSpaceApplier.cs:73`) — the only
side-effecting stage. Decides between a **full structural rebuild** (any topology change) and a
**surgical in-place update** (`ISurgicalAddressSpaceSink.UpdateTagAttributes` /
`UpdateFolderDisplayName` for whitelisted node-irrelevant deltas — the F10b optimization),
falling back to rebuild when the sink lacks the capability or any surgical call fails. After
the address-space work it dispatches two non-blocking hooks: fire-and-forget historian tag
provisioning (`IHistorianProvisioning.EnsureTagsAsync`) and the historized-ref delta feed to
the continuous-historization recorder (`IHistorizedTagSubscriptionSink.UpdateHistorizedRefs`).
Separate `Materialise*` passes (Hierarchy / EquipmentTags / VirtualTags / ScriptedAlarms /
DiscoveredNodes) re-derive nodes from the composition after a rebuild; NodeIds are
folder-scoped (`Commons.OpcUa.EquipmentNodeIds`: `{equipment}/{folder}/{name}`), never the
driver `FullName`, to avoid collisions across identical machines.
The sink boundary (`Commons.OpcUa.IOpcUaAddressSpaceSink`) keeps the whole pipeline SDK-free:
production binds `SdkAddressSpaceSink` via the late-swap `DeferredAddressSpaceSink` (actors resolve
the wrapper at DI time, the OPC UA hosted service swaps the real sink in after `StandardServer`
starts); dev/tests bind the `NullOpcUaAddressSpaceSink` no-op. The forwarding contract of that
wrapper is now guarded by a reflection-exhaustive test (`Commons.Tests/OpcUa/
DeferredSinkForwardingReflectionTests.cs`) — see C-8 for its one blind spot.
### The new resilience dispatch seam (landed this window)
The Phase 6.1 `CapabilityInvoker`, which the 2026-07-08 review cycle found built-but-never-wired
(RESILIENCE-DISPATCH-GAP), is now live in production dispatch via a Polly-free seam that mirrors
`IDriverFactory`:
- **`Core.Abstractions/IDriverCapabilityInvoker.cs`** — the invoker abstraction (3 methods matching
`CapabilityInvoker`'s shapes exactly) plus `NullDriverCapabilityInvoker`, a genuine pass-through.
- **`Core.Abstractions/IDriverCapabilityInvokerFactory.cs`** — per-instance factory abstraction plus
the `Null` factory.
- **`Core/Resilience/DriverCapabilityInvokerFactory.cs`** — the concrete factory: resolves the
driver-type tier, layers per-instance `ResilienceConfig` JSON on the tier defaults via
`DriverResilienceOptionsParser.ParseOrDefaults` (logging the parse diagnostic), calls
`builder.Invalidate(instanceId)` so a respawn with changed options rebuilds cached pipelines, and
snapshots the options once per `Create` so the per-call accessor is allocation-free.
- The Host's `DriverFactoryBootstrap` registers the factory (`Host/Drivers/DriverFactoryBootstrap.cs:6370`),
Runtime resolves it like `IDriverFactory` (`Runtime/ServiceCollectionExtensions.cs:365366`),
`DriverHostActor.SpawnChild` creates one invoker per child (`DriverHostActor.cs:1630`), and
`DriverInstanceActor` wraps its six dispatch sites. `DriverSpawnPlanner` forces a respawn on any
ResilienceConfig change (`DriverSpawnPlan.cs:4046`); `DeploymentArtifact.TryReadSpec` reads the
column null-safely from the artifact (`DeploymentArtifact.cs:168171`). The OTOPCUA0001 analyzer
is wired tree-wide and knows the interface as a wrapper home, so an unwrapped capability call is a
build error.
The layering is genuinely clean — Runtime stays Polly-free, null objects keep every test harness
byte-identical, and the invalidate/respawn pairing closes the options-blind-cache trap. The new
findings below (S-6, S-7, S-8, U-6) are all second-order issues *inside* this new machinery, not
problems with its architecture.
### Supporting Core libraries
- **`Core/Hosting`** — `DriverFactoryRegistry` (driver-type → factory + `GetTier`),
`DriverFactoryRegistryAdapter`, `DriverHost` (id → `IDriver` lifecycle registry).
- **`Core/Resilience`** — Polly pipelines cached per `(DriverInstanceId, HostName, Capability)`
(`DriverResiliencePipelineBuilder`, now with retry/breaker event logging as the interim
observability surface), executed via `CapabilityInvoker` (with the non-idempotent-write no-retry
override), built per instance by `DriverCapabilityInvokerFactory`, fanned out per host by
`AlarmSurfaceInvoker`, observed by `DriverResilienceStatusTracker` for Admin `/hosts`.
- **`Core/Stability`** — `MemoryTracking`, `MemoryRecycle` and `ScheduledRecycleScheduler`
(Tier C recycle via `IDriverSupervisor`), `WedgeDetector`.
- **`Core/Authorization`** — generation-sealed `PermissionTrie` per `(ClusterId, GenerationId)`
cached in `PermissionTrieCache` (CAS-pruned), walked by `TriePermissionEvaluator` against
per-session `UserAuthorizationState`.
- **`Commons`** — immutable record message contracts for the Akka DPS topics, strongly-typed ids,
the deferred sink/publisher pair, `EquipmentScriptPaths`, `OtOpcUaTelemetry`, and now
`Engines/IScriptCacheOwner` (the apply-boundary compile-cache drop capability for
`IVirtualTagEvaluator` implementations).
- **`Configuration`** — `OtOpcUaConfigDbContext` (26 entities), the generation-sealed LiteDB
fallback cache, `ResilientConfigReader`, `DraftValidator`.
- **`Cluster`** — `AddOtOpcUaCluster` / `WithOtOpcUaClusterBootstrap` (now with the extracted,
testable `BuildClusterOptions` setting `KeepOldestOption { DownIfAlone = true }` explicitly — the
#11 premise correction is accurately reflected in the akka.conf comment, the XML doc, and
`SplitBrainResolverActivationTests`), `ClusterRoleInfo`, `ServiceLevelCalculator`, `RoleParser`.
Dependency direction: `Core.Abstractions` (leaf) ← `Configuration``Core`; `Commons` (leaf,
Akka+Audit packages) ← `Cluster`. The composer/applier in `OpcUaServer` reference both
`Configuration` (entities) and `Commons` (sink contracts).
---
## Findings
### 1. Stability
#### S-1 — Applier swallows every sink failure; a deploy is reported applied even when the address space is broken — **High** *(unchanged since 2026-07-08)*
`AddressSpaceApplier` wraps every sink call in catch-all "Safe" helpers that log and continue:
`SafeEnsureFolder`/`SafeEnsureVariable` (`AddressSpaceApplier.cs:612622`), `SafeRebuild`
(`AddressSpaceApplier.cs:373383`), `SafeWriteAlarmCondition`/`SafeMaterialiseAlarmCondition`
(`AddressSpaceApplier.cs:677687`). `AddressSpaceApplyOutcome` (`AddressSpaceApplier.cs:691`)
carries only Added/Removed/Changed counts and `RebuildCalled`**no failure count**. If
`RebuildAddressSpace()` throws, `rebuilt` is still reported `true` (`AddressSpaceApplier.cs:148153`)
and the outcome flows back to the deploy coordinator as a success; the deployment seals while the
running server holds a partially-materialised (or entirely stale) address space. Individual
`EnsureVariable` failures during a materialise pass likewise vanish into per-node Warnings.
The never-fail-a-deploy posture is defensible for the *detached* hooks (provisioning, historized-ref
feed — both correctly isolated), but structural materialisation failures are the deploy's core
contract. This is the domain's highest-priority open item; STATUS.md already queues it as a
"failure-visibility High".
**Recommendation:** add `FailedNodes`/`RebuildFailed` to `AddressSpaceApplyOutcome`, propagate into
the `ApplyAck`/`DeploymentFailed` decision and the audit log, and escalate `SafeRebuild` failure to
at least a degraded ack. Tests should assert the failure surface (the fixture sink can throw).
#### S-2 — `PollGroupEngine.Unsubscribe` blocks the calling thread up to 5 s per subscription — **Medium** *(unchanged)*
`StopState` does a synchronous `task.Wait(TimeSpan.FromSeconds(5))`
(`Core.Abstractions/PollGroupEngine.cs:99112`). `Unsubscribe` is the teardown path drivers call
from OPC UA subscription-management callbacks and actor message handlers; a reader stuck in a slow
network call turns every unsubscribe into a 5-second stall of the calling thread (and N stalls when
tearing down N subscriptions serially). `DisposeAsync` gets this right (parallel cancel, awaited
`WhenAll` with one shared timeout, `PollGroupEngine.cs:213236`); the single-subscription path does
not.
**Recommendation:** add an `UnsubscribeAsync` (or make `Unsubscribe` cancel + hand the await to a
background drain), keeping the "no callback after teardown" guarantee via the existing
LoopTask/CTS-dispose ordering.
#### S-3 — `PollGroupEngine.Subscribe` has no disposed guard — **Low** *(unchanged)*
`Subscribe` racing `DisposeAsync` can insert a fresh `SubscriptionState` after the dispose loop has
snapshotted `_subscriptions.Values` (`PollGroupEngine.cs:7384` vs `213236`), leaking a live poll
loop with no owner. Same pattern in `DriverHost.RegisterAsync` vs `DisposeAsync`
(`Core/Hosting/DriverHost.cs:5371` vs `92106`). Both are shutdown-window races, unlikely in
practice but cheap to close with a `_disposed` flag checked inside the lock / before insert.
#### S-4 — `ScheduledRecycleScheduler` catch-up recycle storm after a stall — **Low** *(unchanged)*
`TickAsync` advances `_nextRecycleUtc` by exactly one interval per fire
(`Core/Stability/ScheduledRecycleScheduler.cs:7482`). If the host was suspended (VM pause, long
outage) across K intervals, the next K ticks each trigger a full Tier C process recycle
back-to-back. **Recommendation:** fast-forward `_nextRecycleUtc` past `utcNow` after a fire.
#### S-5 — `GenericDriverNodeManager` re-walk teardown is not synchronized — **Low** *(unchanged; dormancy now formally documented)*
`BuildAddressSpaceAsync` tears down the previous alarm forwarder and clears `_alarmSinks`
non-atomically (`Core/OpcUa/GenericDriverNodeManager.cs:5873`). Still moot: the #10 analyzer triage
added a pragma at `GenericDriverNodeManager.cs:69` stating outright that this class has "zero
production references" and is exercised only by its own tests — fold this into the U-1 decision.
#### S-6 — NEW: a valid-JSON `ResilienceConfig` with an out-of-range timeout permanently fails every guarded capability call — violates the parser's own never-brick contract — **High**
`DriverResilienceOptionsParser.ParseOrDefaults` defends against *malformed JSON* (`catch
(JsonException)` at `DriverResilienceOptionsParser.cs:7176`), unknown capability names, and a
misapplied Tier-C recycle interval — but performs **no range validation** on the numeric override
values it merges (`DriverResilienceOptionsParser.cs:9195`): `TimeoutSeconds`, `RetryCount`, and
`BreakerFailureThreshold` pass straight through. The pipeline builder then does
`builder.AddTimeout(new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(policy.TimeoutSeconds) })`
(`DriverResiliencePipelineBuilder.cs:111114`). Polly v8 range-validates `TimeoutStrategyOptions.Timeout`
(and `RetryStrategyOptions.MaxRetryAttempts`) at pipeline build time — so `"timeoutSeconds": 0`, a
negative value, or an absurdly large one throws a `ValidationException` inside the
`_pipelines.GetOrAdd` factory on the driver's **first capability call**, and on **every subsequent
call** (a throwing factory caches nothing). Result: subscribe, discover, write, and alarm-ack all
fail persistently for that driver while the driver itself is healthy — precisely the "misconfigured
ResilienceConfig should never brick a working driver" outcome the parser's remarks
(`DriverResilienceOptionsParser.cs:3033`) promise to prevent.
This is operator-reachable: the AdminUI's `ResilienceFormModel` binds `TimeoutSeconds` as a bare
`int?` with **no range validation anywhere in the form model**
(`AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs:3237`), so typing `0` into the timeout
field, publishing, and deploying produces the failure. And because #13 correctly forces a respawn on
any ResilienceConfig change, the bad config takes effect immediately.
**Recommendation:** clamp/reject out-of-range values in `ParseOrDefaults` (the diagnostic
out-parameter already exists — `TimeoutSeconds < 1 → tier default + diagnostic`, cap at Polly's max;
`RetryCount < 0 or > 100 → clamp`), add a parser test per field, and mirror the same bounds as
form-side validation in `ResilienceFormModel`. Defense in depth belongs in the parser: the JSON
column is writable by more paths than the AdminUI form.
#### S-7 — NEW: respawn `Invalidate` race can permanently re-cache a stale-options pipeline — **Low**
`DriverCapabilityInvokerFactory.Create` calls `_builder.Invalidate(driverInstanceId)`
(`DriverCapabilityInvokerFactory.cs:71`) before returning the new invoker, and `DriverHostActor`
processes `plan.ToStop` before `plan.ToSpawn` in the same handler (`DriverHostActor.cs:12781280`)
— but `StopChild` is an asynchronous actor stop. An in-flight capability call still executing inside
the *old* child's handler can reach `GetOrCreate` **after** the new spawn's `Invalidate` has run,
re-populating the cache with a pipeline built from the **old** options snapshot. Because the builder
deliberately ignores options on a cache hit (`DriverResiliencePipelineBuilder.cs:6679`), the new
invoker then serves that stale pipeline **indefinitely** — the transient race has a permanent
consequence, silently undoing the operator's ResilienceConfig change for the affected
(host, capability) keys. The window is small (one in-flight handler) but the failure is invisible.
**Recommendation:** make staleness impossible rather than racing it — include an options generation
(or the options reference) in `PipelineKey`, or have `Create` capture a per-invoker generation the
old invoker can no longer write into. Alternatively, defer the new spawn until the old child's
`Terminated` is observed (the actor-name generation counter shows the respawn already tolerates the
old child lingering).
#### S-8 — NEW: production dispatch hardcodes `isIdempotent: true`; the non-idempotent write safeguard is unreachable — **Medium**
The only production call to `ExecuteWriteAsync` is `DriverInstanceActor.HandleWriteAsync` passing
`isIdempotent: true` unconditionally (`Runtime/Drivers/DriverInstanceActor.cs:597601`); a
repo-wide search finds no caller passing `false`. The `!isIdempotent` no-retry branch in
`CapabilityInvoker.ExecuteWriteAsync` (`CapabilityInvoker.cs:117131`) — the mechanism Phase 6.1
built specifically so non-idempotent device writes are never retried — is production-dead code. The
inline comment's reasoning ("an OPC UA attribute set is idempotent") holds for plain value writes,
but drivers surface write-through targets where a duplicated write is *not* harmless
(command/pulse coils, increment registers, Galaxy supervisory writes), and the
`WriteIdempotentAttribute` the invoker was designed around is never consulted at the seam. Today
this is behavior-neutral (every tier's Write default is `RetryCount: 0`
`DriverResilienceOptions.cs:83, 94, 105`), but the entire point of #13 is that an operator can now
switch Write retries on per instance — and the moment one does, **all** of that driver's writes
retry, with the no-retry safety valve unreachable.
**Recommendation:** either thread the tag-level idempotence signal (from `WriteIdempotentAttribute`
/ driver metadata) through `WriteAttribute` to the dispatch site, or — if all-writes-idempotent is
the deliberate v1 posture — delete the `isIdempotent` parameter from the seam and document the
invariant, so the dead branch (and its per-write allocation, P-4) stops implying a guarantee that
isn't wired.
**Positive observations.** The failure-mode engineering in `Configuration/LocalCache` remains
exemplary (fail-closed `GenerationSealedCache`, atomic pointer swaps, the documented LiteDB traps);
`ResilientConfigReader` and `PermissionTrieCache.Prune` are unchanged and correct. The applier's two
historian hooks are properly detached (`AddressSpaceApplier.cs:255300`). The new remediation code is
itself defensively built: `TryReadSpec` reads `ResilienceConfig` null-safely and the spec survives
absent/null values (`DeploymentArtifact.cs:168181`); the factory logs parse diagnostics instead of
throwing; `Invalidate` is a correct enumerate-and-`TryRemove` pass over the concurrent dictionary;
`DriverSpawnPlanner`'s respawn-on-ResilienceConfig-change with in-place delta for pure DriverConfig
changes (`DriverSpawnPlan.cs:4046`) is exactly the right split; and the `DriverInstanceActor`
wrapping preserved the hard-won no-`ConfigureAwait(false)`-on-the-actor-await rule with an explicit
comment that the invoker's internal `ConfigureAwait(false)` does not leak to the caller
(`DriverInstanceActor.cs:290300`) — backed by a real-invoker actor-context test.
### 2. Performance
#### P-1 — `Compose` parses each tag's TagConfig JSON four times — **Medium** *(unchanged)*
`ExtractTagFullName`, `ExtractTagAlarm`, `ExtractTagHistorize` and `ExtractTagArray` each do their
own `JsonDocument.Parse` of the same blob per tag (`AddressSpaceComposer.cs:418433` calling
`:536686`). A compose over a 10k-tag fleet performs 40k JSON parses (plus the same again on the
artifact-decode side). Compose runs on every deploy and every diff baseline, not on the data hot
path, so this is a scalability tax rather than a latency bug today.
**Recommendation:** parse once per tag into a small `TagConfigIntent` record (which also collapses
the byte-parity duplication — see C-1).
#### P-2 — Per-operation allocations on the authorization hot path — **Medium** *(unchanged)*
`PermissionTrie.CollectMatches` allocates a fresh `HashSet<string>` from the session's LDAP groups
plus a `List<MatchedGrant>` on every call (`Core/Authorization/PermissionTrie.cs:4245`), and
`TriePermissionEvaluator.Authorize` runs per node-operation. A recursive browse or a large
CreateMonitoredItems batch turns this into per-node garbage. `UserAuthorizationState.LdapGroups` is
immutable per membership refresh — the case-insensitive set can be computed once per session
version and reused.
#### P-3 — `PollGroupEngine`: one background task + independent timer per subscription, no cross-subscription batching — **Medium** *(unchanged)*
Each `Subscribe` spawns a dedicated `Task.Run` loop (`PollGroupEngine.cs:7384`). Drivers that map
each OPC UA monitored-item group (or each poll group) to a subscription get linear task/timer
growth, and two subscriptions polling overlapping tag sets at the same interval issue duplicate
protocol reads — the engine has no interval-bucketed scheduler or read coalescing. Fine at dozens
of subscriptions; a scaling wall at hundreds per driver instance.
#### P-4 — Per-write allocation in the non-idempotent write path — **Low** *(unchanged; now also dead code — see S-8)*
`CapabilityInvoker.ExecuteWriteAsync` builds a new options record + `Dictionary` per non-idempotent
write (`Core/Resilience/CapabilityInvoker.cs:117131`) even though `GetOrCreate` keys only on
`(id, host, capability)` and ignores the options after the first build. Since the only production
caller now passes `isIdempotent: true` (S-8), this path is unreachable — resolve it together with
S-8 (either cache the no-retry snapshot or delete the branch).
**Positive observations.** Pipeline resolution remains lock-free `ConcurrentDictionary` reads; the
new factory snapshots options **once per `Create`** so the per-call accessor is allocation-free —
an explicit hot-path improvement over re-parsing per call. The planner's diff is O(N) dictionary
passes with deterministic sorted outputs; the surgical-apply path and its
whitelist-via-`with`-expression technique (`AddressSpaceApplier.cs:630663`) are unchanged and
future-field-safe. The new resilience logging only attaches callbacks when a tracker or logger is
present, so unit-test pipelines stay callback-free.
### 3. Conventions
#### C-1 — "Byte-parity by convention": TagConfig parsing replicated in four projects with comment-enforced sync — **High** *(unchanged)*
`ExtractTagFullName` (and friends) exist as deliberate copies in:
- `OpcUaServer/AddressSpaceComposer.cs:536551` (+ `ExtractTagAlarm`/`ExtractTagHistorize`/`ExtractTagArray`/`TryExtractDeviceHost`)
- `Runtime/Drivers/DeploymentArtifact.cs` (the artifact-decode mirror, per its own comments)
- `Core/OpcUa/EquipmentNodeWalker.cs:193208`
- `Configuration/Validation/DraftValidator.cs:6071`
Each copy carries a "MUST parse identically (byte-parity)" comment. The equality of the deployed
address space across the live-compose and artifact-decode seams — and therefore the correctness of
the diff/no-op-redeploy behavior — rests on humans keeping four JSON parsers in sync. The codebase
already demonstrated the fix once: `EquipmentScriptPaths` (`Commons/Types/EquipmentScriptPaths.cs`)
was created exactly to de-duplicate the `ctx.GetTag` extraction.
**Recommendation:** move the TagConfig/DeviceConfig intent parsing into `Commons` as a single
`TagConfigIntent.Parse(string)` — also resolving P-1 — and keep one cross-seam parity test instead
of N replicated implementations.
#### C-2 — Core depends on Configuration: EF entities and enums are the domain model — **Medium** *(unchanged, accepted-risk)*
`ZB.MOM.WW.OtOpcUa.Core.csproj:16` references `Configuration`; `PermissionTrie` consumes
`Configuration.Enums`, `EquipmentNodeWalker` consumes `Configuration.Entities` directly, and the
composer's plan records are projections of EF entities. A deliberate, consistent choice — flagged
as accepted-risk to document rather than refactor. Notably, the new remediation code got this
boundary **right**: the invoker seam lives in `Core.Abstractions` precisely so Runtime avoids the
Core→Polly closure — new code should keep preferring that pattern.
#### C-3 — `DraftValidator` re-derives the NodeId scheme and hard-codes a driver-type string — **Medium** *(unchanged)*
`ValidateNoEquipmentSignalNameCollision` re-implements the folder-scoped NodeId key by hand
(`Configuration/Validation/DraftValidator.cs:7597`) because `Configuration` cannot reference
`Commons.OpcUa.EquipmentNodeIds` — a third copy of the `{equipment}/{folder}/{name}` scheme.
`ValidateGalaxyTagFullName` hard-codes `dtype != "GalaxyMxGateway"` (`DraftValidator.cs:48`). Both
are symptoms of the same missing shared-contract home as C-1.
#### C-4 — Composer violates its own purity contract with `Trace.TraceWarning` — **Low** *(unchanged)*
The class doc says "Same inputs → same outputs, no logging" (`AddressSpaceComposer.cs:280283`),
but the dangling-predicate-script skip emits `Trace.TraceWarning` (`AddressSpaceComposer.cs:493496`)
— the only `System.Diagnostics.Trace` usage in a Serilog/`ILogger` codebase, invisible in the
production sinks. Either return skipped-alarm ids in the composition for the caller to log, or
accept an injected logger and fix the doc.
#### C-5 — Inconsistent duplicate-key defensiveness inside `Compose` — **Low** *(unchanged)*
`deviceHostById` is built with a deliberate last-wins `foreach` and a comment explaining why
`ToDictionary` (throw-on-dupe) would diverge from the decode side (`AddressSpaceComposer.cs:357363`),
yet `driversById`/`namespacesById`/`scriptsById` use plain `ToDictionary`
(`AddressSpaceComposer.cs:401402, 453`). Pick one posture; the defensive one is already argued for.
#### C-6 — Vestigial `Akka` package reference in Commons — **Low** *(unchanged)*
`Commons.csproj:9` references the `Akka` package but no Commons source uses an Akka type (the new
`IScriptCacheOwner` doesn't either — only doc comments mention actors). Remove it.
#### C-7 — Stale scaffold-era XML docs on load-bearing classes — **Low** *(partially fixed)*
The fixdocs sweep (`3b5ef439`) fixed `CapabilityInvoker`'s doc duplication, but both narratives this
finding flagged survive verbatim: `AddressSpaceApplier`'s class doc still describes the F14 scaffold
("For now we record the work", "the SDK adapter that lands in F10b will decide…" —
`AddressSpaceApplier.cs:726`) although both features shipped, and `DriverHost`'s doc still promises
"per-process isolation for Tier C … implemented in Phase 2 via named-pipe RPC"
(`Core/Hosting/DriverHost.cs:510`), an architecture superseded by the out-of-repo gateways. In a
codebase whose XML documentation is otherwise a genuine asset, stale top-of-class narratives are
actively misleading.
#### C-8 — NEW: the deferred-sink forwarding guard's "exhaustive" interface detection is namespace-filtered — **Low**
`DeferredSinkForwardingReflectionTests.DeferredAddressSpaceSink_implements_exactly_the_known_forwarding_interfaces`
— the guard-of-the-guard whose whole job is catching a *new* capability interface added to the
wrapper — filters `GetInterfaces()` to `i.Namespace == typeof(IOpcUaAddressSpaceSink).Namespace`
(`Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs:44`), i.e. only
`ZB.MOM.WW.OtOpcUa.Commons.OpcUa`. Commons already has sibling namespaces the wrapper could
plausibly grow an interface from (`Commons.Engines` — home of the brand-new `IScriptCacheOwner`
and `Commons.Types`): a future forwarding interface declared outside `Commons.OpcUa` would be
silently excluded from `ForwardingInterfaces` enforcement, re-opening exactly the
F10b/PR#423 inert-capability trap class this guard exists to close. The per-method forwarding test
and the `DummyArg` fabricator are otherwise well built (the `DispatchProxy` recorder auto-covers
future members; bool-returning members return `true` so capability-sniffing short-circuits still
record).
**Recommendation:** drop the namespace filter and instead exclude a small explicit allowlist of
known non-forwarding interfaces (e.g. `IDisposable`), so any *unknown* interface trips the guard
regardless of where it lives.
**Positive observations.** Naming and structure remain highly consistent, and the new code follows
the house patterns exactly: the seam mirrors `IDriverFactory`, both null objects are private-ctor
singletons, options records keep `Validate()`-style diagnostics, and every new public member is
documented. Two convention-enforcement mechanisms landed that materially raise the floor: the
OTOPCUA0001 analyzer is wired tree-wide (an unwrapped capability call is now a build error, with
the one remaining pragma carrying a full rationale — `GenericDriverNodeManager.cs:69`), and the
reflection forwarding guard turns the deferred-sink trap from a code-review convention into a
failing test. The #11 premise correction is honest engineering — the SBR comments/docs now state
that the typed option is reinforcing, not the activator, instead of quietly keeping the wrong
war story.
### 4. Underdeveloped Areas
#### U-1 — Dead/dormant code retained in Core: `GenericDriverNodeManager`, `EquipmentNodeWalker`, `TryParseRelayBody` — **Medium** *(unchanged; evidence strengthened)*
- `GenericDriverNodeManager` (`Core/OpcUa/GenericDriverNodeManager.cs`) has no production references
— now stated *in the source itself* by the #10 triage pragma ("zero production references",
`GenericDriverNodeManager.cs:69`).
- `EquipmentNodeWalker` (`Core/OpcUa/EquipmentNodeWalker.cs:160165`) self-declares "retained for
unit-test support only" — ~280 lines plus `IdentificationFolderBuilder` and a full test suite for
a path that cannot run in production. Its `ExtractFullName` copy is one of the four C-1 duplicates.
- `EquipmentScriptPaths.TryParseRelayBody` (`Commons/Types/EquipmentScriptPaths.cs:164172`)
serviced the deleted relay→alias converter; only tests call it.
Dormant code with green tests is worse than deleted code: it passes every gate while silently
diverging from the live path — the review cycle's own RESILIENCE-DISPATCH-GAP episode proved the
genre. **Recommendation:** delete all three (git preserves them), or move the walker into the test
project if it must survive as a fixture.
#### U-2 — The entire Tier C recycle machinery has no `IDriverSupervisor` implementation — **Medium** *(unchanged; now accreting further)*
`MemoryRecycle`, `ScheduledRecycleScheduler`, and `DriverResilienceStatusTracker.RecordRecycle`
all drive `IDriverSupervisor.RecycleAsync`, but a repo-wide search still finds **zero concrete
implementations**. The gap is now *growing* supporting surface: `DriverResilienceOptionsParser`
carefully gates `RecycleIntervalSeconds` to "Tier C only" (`DriverResilienceOptionsParser.cs:101110`)
and the AdminUI authors the field — configuration and validation for an execution path that does
not exist. Either document it as speculative infrastructure for a future tier-migration workflow or
prune it alongside U-1.
#### U-3 — Continuous-historization restart convergence: mechanically present, unverified — **Low** *(downgraded from Medium; prior framing partially superseded)*
Re-analysis this cycle shows the prior "after a restart the recorder historizes nothing until the
next deploy" framing was too strong: `DriverHostActor.RestoreApplied` fires a `RebuildAddressSpace`
on bootstrap (`DriverHostActor.cs:12931320`), and `OpcUaPublishActor.HandleRebuild` diffs the
restored artifact against an empty `_lastApplied` baseline (`OpcUaPublishActor.cs:290340`) — a
full-add plan whose `Apply` feeds the **complete** historized set through `FeedHistorizedRefs`
(`AddressSpaceApplier.cs:310353`). The spawn-time empty set
(`Runtime/ServiceCollectionExtensions.cs:269278`) is therefore covered by the restore path, and the
recorder is deliberately spawned before the applier wiring so the feed can reach it. What remains is
exactly what CLAUDE.md Known Limitation 2 (as reworded by the remediation) says: **no
restart-convergence test** asserting the recorder re-registers the deployed set after a process
restart, and no live end-to-end verification. Given the subsystem's history of
mechanically-present-but-inert paths, that test is cheap insurance and should be written.
#### U-4 — Additive-only ACL model (no Deny) is a documented v2.0 gap — **Low** *(unchanged)*
`PermissionTrie` is explicitly pure-union ("no explicit Deny in v2.0",
`Core/Authorization/PermissionTrie.cs:1317`): a broad cluster-root grant cannot be carved back at
a sub-scope. Acceptable for the current flat-role deployments, but it bounds how fine-grained OT
authorization can get — keep visible in the security docs.
#### U-5 — Test coverage: `ClusterRoleInfo` remains the riskiest untested seam — **Low** *(narrowed from prior)*
This cycle materially improved the domain's test posture: `SplitBrainResolverActivationTests`
(Cluster.Tests), `DriverCapabilityInvokerFactoryTests` (invalidate-per-instance, malformed-config
fallback, override-reaches-execution), pipeline-builder logging tests, and the Commons forwarding
reflection guard all landed. Still missing: `ClusterRoleInfo` — the only concurrency-bearing class
in the Cluster project (lock + subscriber actor + event fan-out) — has **no test** (Cluster.Tests
still covers only pure classes plus the SBR option), and the S-1 applier failure surface and S-3
dispose races remain untested because the surfaces don't exist. An Akka TestKit harness for
`ClusterRoleInfo` leader-change sequencing would close the riskiest untested seam.
#### U-6 — NEW: bulkhead is advertised, authored, parsed — and never applied — **Medium**
The resilience surface documents "retry / circuit-breaker / bulkhead / tracker telemetry" in the
seam docs (`IDriverCapabilityInvoker.cs:46`), the analyzer's diagnostic message
(`UnwrappedCapabilityCallAnalyzer.cs:105`), and the tracker's "Polly bulkhead-depth column" doc;
`DriverResilienceOptions` carries `BulkheadMaxConcurrent`/`BulkheadMaxQueue` defaults
(`DriverResilienceOptions.cs:2325`); the parser merges operator overrides for both
(`DriverResilienceOptionsParser.cs:116117`); and the AdminUI form authors them
(`ResilienceFormModel.cs:1820`). But `DriverResiliencePipelineBuilder.Build`
(`DriverResiliencePipelineBuilder.cs:99177`) adds only timeout → retry → circuit-breaker — there is
no `AddConcurrencyLimiter` (or any rate/concurrency strategy) anywhere in `src/`. The bulkhead is a
dead knob end-to-end: an operator can set it, the deploy respawns the driver, and nothing changes —
the same "configured but inert" genre as the dispatch gap this cycle just fixed, now one layer
deeper.
**Recommendation:** either add the concurrency limiter to the pipeline (keyed like the breaker,
per host) with a factory test proving the queue bound engages, or strip the fields from the
options/parser/form and the word "bulkhead" from the docs until the strategy ships. Don't leave the
knob authored-but-unwired — this codebase's history says it will be trusted.
---
## Maturity Ratings
| Dimension | 2026-07-08 | 2026-07-12 | Justification |
|---|---|---|---|
| Stability | **4** | **4** | The deliberate failure-mode engineering (fail-closed caches, detached hooks, null-object seams) still defines the domain, and the new dispatch wiring was built carefully (respawn-on-change, invalidate pairing, actor-context preservation). But nothing on the stability backlog moved — S-1's swallow-everything deploy reporting remains the headline — and the new machinery introduced a genuine operator-reachable bricking vector (S-6) that directly contradicts its own never-brick contract. |
| Performance | **3** | **3** | All four performance findings are untouched (4× JSON parse, per-node authorization garbage, task-per-subscription engine, per-write allocation). The new code is hot-path conscious (once-per-Create options snapshot, callback-free pipelines without observers) but adds no throughput improvements to the existing paths. |
| Conventions | **4** | **4** | Already exceptionally consistent; this window added *mechanical enforcement* (tree-wide OTOPCUA0001 as a build error, the reflection forwarding guard) which is the right direction — but the systemic byte-parity duplication (C-1/C-3) is untouched, the two flagged stale class narratives survived a dedicated fixdocs pass (C-7), and the new guard itself has a namespace blind spot (C-8). |
| Underdeveloped areas | **3** | **3** | The cycle's biggest dormancy (CapabilityInvoker never dispatched) is genuinely fixed and analyzer-guarded, and U-3 turned out mostly mechanically closed. But U-1's three dead code paths and U-2's implementation-less Tier C machinery are untouched — the latter now *accreting* config/validation surface — and a new authored-but-inert knob shipped (U-6 bulkhead). The pattern the review flagged (built, tested, unreachable) is still being produced. |