44f6408e1c
Every round-2 finding re-verified against code (73 closed / 31 partial / 97 carried); matrix 19/28 cells up, one down. New Critical 03/S14 (AdminUI FallbackPolicy 401s every mesh gRPC map on a login-enabled central; masked on every rig gate by DisableLogin=true). New Highs 03/S15, 05/STAB-19, 05/STAB-18, 03/S16 — the stale-Good family at four seams.
395 lines
37 KiB
Markdown
395 lines
37 KiB
Markdown
# Architecture Review — Core Composition Pipeline and Core Libraries
|
||
|
||
**Date:** 2026-08-19
|
||
**Commit:** `12b0035a` (master, clean tree)
|
||
**Note:** This is the **second update** of this report: round 1 ran 2026-07-08 at `9cad9ed0`, round 2
|
||
2026-07-12 at `f6eaa267`. The window since `f6eaa267` is the largest this domain has ever absorbed —
|
||
207 commits touching Core + OpcUaServer alone (~23k insertions / ~27k deletions across 213 files),
|
||
including the Round-2 remediation plans R2-02/R2-07/R2-10/R2-11 landing in this domain, the v3
|
||
dual-namespace address space (Batch 4), the **2026-08-15 surgical-deploys** rework
|
||
(`AddressSpaceChangeClassifier` whitelist + delta materialisation), the gzip-compressed deployment
|
||
artifact, the per-cluster mesh program's Cluster-project additions (bootstrap guard, seed-order and
|
||
transport validators), the ClusterClient→gRPC migration's Commons DTO layer, and the #520 API-key/ACL
|
||
workstream's `Core/Authorization` rework. The #522 deletion of the inert Tier-C recycle machinery and
|
||
the LiteDB `LocalCache` deletion (`2bae1b4c`) also removed two long-standing subjects of this report.
|
||
|
||
**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 seams, `PollGroupEngine`, historian seams, `ResiliencePolicyRanges`, `TagConfigJson`)
|
||
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons` (message contracts, deferred sinks, `V3NodeIds`/`RawPaths` identity authorities, `TagConfigIntent`, CSV, mesh DTOs)
|
||
- `src/Core/ZB.MOM.WW.OtOpcUa.Configuration` (EF config persistence, draft validation — the LiteDB LocalCache subsystem is deleted)
|
||
- `src/Core/ZB.MOM.WW.OtOpcUa.Cluster` (Akka bootstrap, bootstrap guard, seed-order/transport/telemetry validators, ServiceLevel)
|
||
- The composition pipeline in `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/` — `AddressSpaceComposer` / `AddressSpacePlanner` (`AddressSpacePlan.cs`) / `AddressSpaceChangeClassifier` / `AddressSpaceApplier` — plus its Runtime consumers `OpcUaPublishActor` and `DeploymentArtifact`, and `ControlPlane/AdminOperations/ConfigComposer` (artifact + RevisionHash).
|
||
|
||
---
|
||
|
||
## Prior-finding status (f6eaa267 → 12b0035a)
|
||
|
||
Every prior finding was re-verified against current code (not STATUS.md or plan-doc claims).
|
||
|
||
| ID | One-line description | Verified status |
|
||
|---|---|---|
|
||
| S-1 | Applier swallows every sink failure; deploy reported applied even when broken | **SUBSTANTIALLY CLOSED.** `AddressSpaceApplyOutcome` now carries `RebuildFailed` + `FailedNodes` (`AddressSpaceApplier.cs:1297–1303`); every `Safe*` helper returns bool and callers tally (`:96–99, 176, 312, 338`); the five `Materialise*` passes return failed-node counts (`:779, 828, 939, 998, 1145`); `OpcUaPublishActor` sums both channels, logs at Error, and fires the `otopcua.opcua.apply.failed` meter (`OpcUaPublishActor.cs:503–509, 567–575`); a degraded **non-rebuild** apply does not advance `_lastApplied`, so the next deploy re-diffs and repairs (`:534–544`) — replacing the accidental full-pass self-healing the delta path removed. `AddressSpaceApplierFailureSurfaceTests` exists. **Residue re-filed as S-9:** the deploy ACK is still sent before, and independent of, the apply outcome. |
|
||
| S-2 | `PollGroupEngine.Unsubscribe` blocks up to 5 s per subscription | **CLOSED in production.** `UnsubscribeAsync` added (`PollGroupEngine.cs:191–196`, shared `TryTake` `:203–212`, async drain `:230–239`); all six poll drivers call it (Modbus:1354, FOCAS:851, TwinCAT:596, AbLegacy:502, S7:2102, Sql:583). The sync `Unsubscribe` is retained for compatibility and still blocks (`:225`, `DrainTimeout` `:242`) but has no production caller. |
|
||
| S-3 | No disposed guard: `Subscribe`/`RegisterAsync` race `DisposeAsync` | **STILL OPEN** — no `_disposed` in `PollGroupEngine.cs` (`Subscribe` `:156–167` vs `DisposeAsync` `:438–457`) or `Core/Hosting/DriverHost.cs` (`:58–63` vs `:92–106`). |
|
||
| S-4 | `ScheduledRecycleScheduler` catch-up recycle storm | **CLOSED BY DELETION** — `cbb88229` (#522) deleted the class; `Core/Stability/` now holds only `WedgeDetector.cs`. |
|
||
| S-5 | `GenericDriverNodeManager` re-walk teardown unsynchronized | **OPEN-BUT-MOOT** — class still present, still zero production references; folded into U-1. |
|
||
| S-6 | Valid-JSON out-of-range ResilienceConfig permanently bricks every guarded capability call | **CLOSED** (R2-02). `ClampPolicy` clamps timeout/retry/breaker with diagnostics (`DriverResilienceOptionsParser.cs:110–191`); `ResiliencePolicyRanges` in Core.Abstractions (`ResiliencePolicyRanges.cs:11`); belt-and-braces `Math.Clamp` in the builder (`DriverResiliencePipelineBuilder.cs:136`). All numerics handed to Polly are covered. |
|
||
| S-7 | Respawn `Invalidate` race re-caches a stale-options pipeline | **CLOSED** (R2-02). `PipelineKey` carries `Generation` (`DriverResiliencePipelineBuilder.cs:208`, keyed `:86–91`); factory `Interlocked.Increment` per `Create` (`DriverCapabilityInvokerFactory.cs:75, 91`). |
|
||
| S-8 | Dispatch hardcodes `isIdempotent: true`; non-idempotent safeguard unreachable | **CLOSED** (R2-02). The single production write dispatch passes `isIdempotent: false` with rationale (`DriverInstanceActor.cs:800–813`); parser forces Write/AlarmAck retry→0 (`DriverResilienceOptionsParser.cs:154–158`) and Initialize retry→0 (`:165–169`, from `dac7e188`). |
|
||
| P-1 | `Compose` parses each tag's TagConfig JSON four times | **CLOSED** (R2-11). `TagConfigIntent.Parse` once per raw tag (`AddressSpaceComposer.cs:769`); minor residue: each UNS reference re-parses its backing tag's blob (`:842`), so a tag referenced by N equipment parses N+1 times — trivial next to the old 4×-per-seam. |
|
||
| P-2 | Per-operation allocations on the authorization hot path | **OPEN, MITIGATED.** `PermissionTrie.CollectMatches` still allocates per walk (`PermissionTrie.cs:41, 44`), but stage-6 hot seams (read/browse/subscribe) hit `SessionAclDecisionCache` first (`OpcUaServer/Security/SessionAclDecisionCache.cs`), so the walk runs on cache miss only; write/alarm/HistoryRead still walk per request. Narrowed to Low. |
|
||
| P-3 | `PollGroupEngine`: task-per-subscription, no coalescing | **STILL OPEN** — `Task.Run` per `Subscribe` (`PollGroupEngine.cs:165`). The engine gained fixed-rate pacing + overrun metering this window (reviewed below — see S-11). |
|
||
| P-4 | Per-write allocation in the non-idempotent write path | **CLOSED** — the no-retry snapshot is cached once per invoker (`CapabilityInvoker.cs:32, 138, 161`). |
|
||
| C-1 | Byte-parity TagConfig parsing replicated in four projects | **CLOSED** (R2-11). `TagConfigIntent`/`DeviceConfigIntent` in `Commons/Types/`; `grep "MUST parse identically" src/` = 0; composer (`:769, :842`), artifact decode, and `DraftValidator` (`:488`) all consume the single authority; `TagConfigCorpusParityTests` guards compose≡decode. |
|
||
| C-2 | Core depends on Configuration; EF entities are the domain model | **STILL OPEN (accepted-risk)** — `Core.csproj:16`. |
|
||
| C-3 | `DraftValidator` re-derives NodeId scheme + hard-codes `"GalaxyMxGateway"` | **CLOSED BY REWORK.** The v3 validator consumes the shared authorities (`V3NodeIds` at `DraftValidator.cs:67, 190–191, 269–273`; `RawPaths.ValidateSegment` `:460`); `GalaxyMxGateway` no longer appears in Configuration. |
|
||
| C-4 | Composer violates purity contract with `Trace.TraceWarning` | **STILL OPEN** — `AddressSpaceComposer.cs:627` (now with an acknowledging comment `:618`; `AddressSpaceComposerPurityTests` pins determinism, not no-logging). |
|
||
| C-5 | Inconsistent duplicate-key defensiveness in `Compose` | **STILL OPEN, softened** — defensive `GroupBy…First()` at `:816, :874–877, :959–968, :995` coexists with throw-on-dupe `ToDictionary` at `:587, :689` (both keyed by PK, so unreachable in practice). |
|
||
| C-6 | Vestigial `Akka` package reference in Commons | **STILL OPEN** — `Commons.csproj:9`; zero `using Akka` in Commons source (the new mesh DTO mapper did not need it either). |
|
||
| C-7 | Stale scaffold-era XML docs on load-bearing classes | **PARTIALLY FIXED, new staleness added.** The F14-scaffold narrative is gone from the applier, but the rewritten doc contradicts itself: the PureRemove bullet describes the shipped in-place teardown **and** says "Maps to a full rebuild until R2-07 Phase 2 ships" (`AddressSpaceApplier.cs:26–28`), AddRemoveMix likewise (`:28–29`) — both phases shipped a year of commits ago. `DriverHost.cs:7–9` still promises "Phase 2 via named-pipe RPC". New #522 residue: `DriverTier.cs:18` and `DriverFactoryBootstrap.cs:179` describe the deleted recycle machinery in the present tense. |
|
||
| C-8 | Deferred-sink forwarding guard is namespace-filtered | **STILL OPEN** — `DeferredSinkForwardingReflectionTests.cs:44` and `:106` still filter `i.Namespace == typeof(IOpcUaAddressSpaceSink).Namespace`. The guard *did* do its job this window (the surgical remove members and batched `WriteValues` are all forwarded), but the blind spot stands. |
|
||
| U-1 | Dead code retained: `GenericDriverNodeManager`, `EquipmentNodeWalker`, `TryParseRelayBody` | **STILL OPEN** — all three present (`Core/OpcUa/`; `EquipmentScriptPaths.cs:228`), references are doc-comments and tests only. #522 proved the codebase can delete dormant machinery; this trio survived the purge. |
|
||
| U-2 | Tier C recycle machinery has no `IDriverSupervisor` implementation | **CLOSED BY DELETION** — #522 (`cbb88229`) removed `MemoryRecycle`, `ScheduledRecycleScheduler`, `IDriverSupervisor`, `MemoryTracking`, and the `RecycleIntervalSeconds` knob end-to-end; only tombstone comments remain (see C-7 residue). This resolves the "accreting surface for an execution path that does not exist" trajectory the last two reviews flagged. |
|
||
| U-3 | Continuous-historization restart convergence unverified | **CLOSED.** Recorder ref-set convergence unit-tested (`ContinuousHistorizationRecorderTests.cs:27–100`); applier feed tested (`AddressSpaceApplierProvisioningTests`); live end-to-end gate ran 2026-08-01/04 and #491 closed (with the `ConfigureAwait(false)`-destroys-`Self` defect found and fixed, plus #537/#538 — outside this domain's files but the gate discharged this finding's residue). |
|
||
| U-4 | Additive-only ACL model (no Deny) | **STILL OPEN (by design)** — `PermissionTrie.cs:13–15`. Stakes rose materially with #520 (ACLs now gate the whole data plane), so the limitation is now security-relevant, not latent. |
|
||
| U-5 | `ClusterRoleInfo` untested; Cluster coverage thin | **CLOSED** — `ClusterRoleInfoTests.cs` exists; the Cluster.Tests suite grew to 16 files covering the bootstrap guard/coordinator, seed validators, and transport/telemetry validators. |
|
||
| U-6 | Bulkhead advertised, authored, parsed — never applied | **CLOSED** (R2-02) — knob deleted end-to-end; `DriverResilienceOptionsTests.cs:15, 40` is the "options properties are exactly the pipeline-wired set" inertness guard. Cosmetic residue: `WedgeDetector.cs:65–76` still names a `BulkheadDepth` parameter. |
|
||
|
||
Net: **16 of 26 prior findings verifiably closed** (five by the R2-02 resilience hardening, two by
|
||
R2-11's parse consolidation, two by the #522 deletion, and — the headline — S-1's failure surface
|
||
finally built and consumed). The remediation posture inverted from the last cycle: this window
|
||
targeted this domain's backlog directly, and the closures are real code with real tests, not claims.
|
||
What remains open is dominated by accepted-risk items (C-2, U-4) and hygiene (C-4/5/6/7/8, U-1, S-3).
|
||
|
||
---
|
||
|
||
## Architecture Overview
|
||
|
||
### Composition pipeline (deploy → address space) — now four stages
|
||
|
||
1. **`AddressSpaceComposer.Compose`** (`OpcUaServer/AddressSpaceComposer.cs`) — pure static projection
|
||
from EF entities into an `AddressSpaceComposition`. v3 reshaped it around the dual namespace: it
|
||
emits the **Raw subtree** (`RawContainerNode` Folder→Driver→Device→TagGroup + `RawTagPlan` keyed by
|
||
RawPath, `:708–794`) and the **UNS projection** (areas/lines/equipment + `UnsReferenceVariable`
|
||
mirror records, `:803–860`), all ordinally sorted. Tag intent parses once per tag through the
|
||
Commons `TagConfigIntent` authority (`:769`). The critical 2026-08-15 design decision lives in the
|
||
record shape: `UnsReferenceVariable` carries `Backing{IsHistorized,HistorianTagname,IsArray,ArrayLength}`
|
||
copied from the backing tag's intent (`:838–855`), so a raw-side historian/shape edit makes the
|
||
reference record unequal and the planner emits the **coincident** delta that repairs the mirror —
|
||
staleness prevention encoded in data, not in classifier logic.
|
||
|
||
2. **`AddressSpacePlanner.Compute`** (`AddressSpacePlan.cs:160–252`) — still a pure diff. New diff sets:
|
||
raw containers/tags keyed by RawPath (a rename = remove+add, the immutable-NodeId rule) and UNS
|
||
references keyed by the **stable row id, not NodeId** (`:216–219`) — the keying choice that makes a
|
||
backing-tag rename a surgical re-point instead of a teardown.
|
||
|
||
3. **`AddressSpaceChangeClassifier.Classify`** (`AddressSpaceChangeClassifier.cs:48–125`) — the pure
|
||
policy layer added by R2-07 and rewritten by surgical-deploys. First-match-wins; **default-closed
|
||
Rebuild** for any node-affecting change, with three `with`-equality whitelists
|
||
(`VtagDeltaIsNodeIrrelevant` `:133`, `TagDeltaIsSurgicalEligible` `:156`,
|
||
`RawTagDeltaIsSurgicalEligible` `:174`) whose record-equality construction automatically closes
|
||
over any future field. The WP4 alarm-notifier guard (alarm-bearing raw delta ⇒ Rebuild) is
|
||
preserved and its narrowing (referencing-set-only deltas are node-inert for value tags) is
|
||
documented in place with the blast-radius history (`:61–92`).
|
||
|
||
4. **`AddressSpaceApplier.Apply`** (`AddressSpaceApplier.cs:85–339`) — routes by kind: in-place
|
||
surgical updates (folder renames, equipment/raw tag attributes, identity-preserving UNS-reference
|
||
repairs `:275–298`), scoped removes with terminal Bad/`RemovedConditionState` writes
|
||
(`ApplyPureRemove` `:360–446`), and the one-way **rebuild ratchet** on any surgical `false`/throw
|
||
(`:312`). A degraded outcome is now first-class (`AddressSpaceApplyOutcome.RebuildFailed`/
|
||
`FailedNodes`). The five `Materialise*` passes gained **delta-subset overloads** (surgical-deploys):
|
||
a non-rebuild apply materialises the plan's delta, not the composition — adding one tag is O(change)
|
||
instead of ~50k sink calls on the pinned publish thread — while `MaterialiseHierarchy` deliberately
|
||
stays full-composition (ancestors must exist; `:767–774`). `OpcUaPublishActor` holds the other half
|
||
of the contract: a degraded non-rebuild apply does **not** advance `_lastApplied`
|
||
(`OpcUaPublishActor.cs:511–544`), so the next deploy re-diffs and repairs — the designed replacement
|
||
for the accidental self-healing the full passes used to provide.
|
||
|
||
### Artifact chain (`ConfigComposer` → `DeploymentArtifact`)
|
||
|
||
`ConfigComposer.Pack` (`ControlPlane/AdminOperations/ConfigComposer.cs:99–109`) gzips the
|
||
deterministic snapshot and hashes the **compressed** bytes in one step, so blob and `RevisionHash`
|
||
can never be derived from different byte sequences — the invariant the FetchAndCache verify path and
|
||
LocalDb cache rest on. It is directly pinned by
|
||
`ConfigComposerTests.Blob_is_gzip_and_the_revision_hash_is_over_the_compressed_bytes`, whose comment
|
||
correctly observes that hashing the uncompressed JSON would fail every fleet fetch while every
|
||
hash-shape test stayed green. `DeploymentArtifact.Open` (`Runtime/Drivers/DeploymentArtifact.cs:117`)
|
||
sniffs the gzip magic (`:164`), inflates under a 512 MiB guard (`MaxDecompressedBytes` `:133`,
|
||
enforced `:177–181`), and surfaces every decompression failure as `JsonException` so it lands in the
|
||
existing malformed-JSON catches. `ParsedDeploymentArtifact` parses the blob **once** per apply and
|
||
serves all four consumers (driver specs, cluster scope, data-plane authority, composition) — a real
|
||
hot-path improvement over the old parse-per-question statics. The lenient static wrappers survive for
|
||
the boot/restore paths, which is where the one new stability finding lives (S-10).
|
||
|
||
### Cluster project — bootstrap guard and fail-fast validators
|
||
|
||
The Cluster project roughly tripled: `ClusterBootstrapGuard`/`ClusterBootstrapCoordinator` (the
|
||
simultaneous-cold-start founder election), `AkkaClusterOptionsValidator` (conditional self-first seed
|
||
ordering), `SplitTopologyTransportValidator`, `MeshTransportOptionsValidator` (the deleted
|
||
`ClusterClient` mode now **fails loudly**, never falls back — `MeshTransportOptionsValidator.cs:45–55`),
|
||
and `TelemetryOptions`/`ConfigSource` validators. The engineering quality is high — the guard makes
|
||
its one-shot join decision *before* `JoinSeedNodes` from an explicit TCP reachability signal
|
||
(deliberately avoiding the deleted `SelfFormAfter`'s mid-handshake re-form), `BuildDowningHocon`
|
||
throws on an unknown strategy, and every new class has a test file including real 2-node in-process
|
||
clusters. The findings here (C-9, S-12) are consistency gaps between the validators, not design
|
||
flaws.
|
||
|
||
---
|
||
|
||
## Findings
|
||
|
||
### 1. Stability
|
||
|
||
#### S-9 — The deploy ACK is decoupled from the address-space apply outcome: a degraded apply still seals the deployment green — **Medium** *(new; the residue of S-1)*
|
||
|
||
`DriverHostActor.HandleDeploy` upserts `NodeDeploymentState = Applied` and sends
|
||
`ApplyAckOutcome.Applied` **before** it Tells `RebuildAddressSpace` to the publish actor
|
||
(`Runtime/Drivers/DriverHostActor.cs:1931–1944`); the address-space work then runs asynchronously on
|
||
the publish actor's pinned thread. Everything S-1 asked for now exists inside that async leg — the
|
||
failure tally, the Error log, the `otopcua.opcua.apply.failed` meter, the no-advance re-diff repair
|
||
(`OpcUaPublishActor.cs:503–575`) — but none of it can reach the ack that was already sent. A
|
||
deployment whose address-space apply degrades (or whose rebuild throws outright,
|
||
`OpcUaPublishActor.cs:582–585`) seals **Applied** fleet-wide; the coordinator, `/fleet`, and the
|
||
audit trail all record success, and only the node-local log/meter disagree.
|
||
|
||
This is a smaller residue than the original S-1 (the failure is now *visible* and *self-repairing*,
|
||
where before it was silent and permanent), and making the ack await the apply would change the deploy
|
||
protocol's timing semantics — but the honest fix need not block: a follow-up degraded-ack message (or
|
||
a `NodeDeploymentState` status amendment posted by the publish actor after the apply completes) would
|
||
let central distinguish "artifact accepted" from "address space converged".
|
||
|
||
#### S-10 — The malformed-artifact leg of the #485 defect class is still open on the lenient boot/restore parse paths — and gzip added a new malformed shape — **Medium** *(new)*
|
||
|
||
#485 established the rule "no bytes is no answer, not an empty configuration", and the zero-length
|
||
guards are faithfully in place (`DriverHostActor.cs:2200–2204, 2487–2493`;
|
||
`OpcUaPublishActor.cs:452–463`). But the **non-empty-malformed** case takes a different route on the
|
||
boot-from-cache and bootstrap-restore paths: `ReconcileDriversFromBlob` calls the lenient
|
||
`DeploymentArtifact.ParseDriverInstances(blob, node)` (`DriverHostActor.cs:2206`), which swallows
|
||
`JsonException` to an **empty spec list** (`DeploymentArtifact.cs:263–266`), and
|
||
`ApplyDriverSpecs` then plans every running child for `StopChild` (`:2225–2235`) — the exact
|
||
"silently takes this node's entire field I/O down" consequence the #485 comment eleven lines above
|
||
describes. `PushDesiredSubscriptionsFromArtifact` has the same shape: the lenient `ParseComposition`
|
||
(`DriverHostActor.cs:2498`; swallow at `DeploymentArtifact.cs:307–310`) yields an empty composition →
|
||
an empty desired set → "drops the live subscription handle rather than re-subscribing" (its own
|
||
guard comment, `:2483–2486`). The surrounding `try/catch` there is dead code — the wrapper never
|
||
throws.
|
||
|
||
The apply paths are safe (they use `Open`, which throws, and the publish actor deliberately documents
|
||
why — `OpcUaPublishActor.cs:465–474`). The exposure is boot/restore with corrupt-but-nonempty bytes.
|
||
That window used to be negligible; the gzip change widens it: a truncated or bit-flipped gzip stream
|
||
is a new malformed shape that is *guaranteed* to surface as the swallowed `JsonException`
|
||
(`DeploymentArtifact.cs:196`). Mitigations that keep this Medium rather than High: the LocalDb cache
|
||
verifies SHA-256 on read, and SQL varbinary truncation is rare. The doc comment
|
||
(`DriverHostActor.cs:2190–2195`) shows the leniency is *known* ("the lenient 'malformed ⇒ no specs'
|
||
parse those paths have always had") — but #485's own rationale applies verbatim to it: nothing
|
||
legitimate produces unparseable bytes, so the honest reading is "no answer", i.e. warn and keep the
|
||
running children/subscriptions, exactly as the zero-length branch already does.
|
||
|
||
#### S-11 — `PollGroupEngine` batch path advances the change baseline before raising, so a throwing sink permanently loses that cycle's changes — **Low** *(new)*
|
||
|
||
The new per-cycle batch callback (`adebad71`) updates `LastValues` per tag **before** the batch raise
|
||
(`PollGroupEngine.cs:406` vs `:416`); an `onBatch` (or mid-loop `onChange`) throw is absorbed and
|
||
counted as a generic poll failure, but the diff baseline has already advanced, so the lost changes are
|
||
never re-raised. The pacing math itself is correct (fixed-rate with a `Task.Yield` tight-loop guard,
|
||
`:286–313`) and the overrun meter's slow-success/slow-failure conflation is documented (`:340–349`).
|
||
|
||
#### S-12 — Two role authorities can wire a node inconsistently: Akka gossips `driver` while the Host wires no driver stack — **Medium** *(new)*
|
||
|
||
`Program.cs:61–63` derives `hasAdmin`/`hasDriver` — the switches for the *entire* conditional host
|
||
wiring — solely from `OTOPCUA_ROLES`, while the Akka cluster roles resolve configured-first via
|
||
`RoleParser.ResolveEffectiveRoles` (`RoleParser.cs:115–125`; PostConfigure in
|
||
`Cluster/ServiceCollectionExtensions.cs`). A node configured only through `Cluster:Roles` (a shape
|
||
#532's own remediation made first-class on the Akka side) joins the mesh advertising `driver`,
|
||
becomes an expected-ack member of every deployment, and times them out forever because no driver
|
||
stack was registered. #532 fixed the env-only→no-Akka-roles direction; this is the surviving inverse.
|
||
The fix is one line — resolve `Program.cs`'s roles through the same `ResolveEffectiveRoles` — plus a
|
||
guard test asserting the two views agree.
|
||
|
||
#### S-3 — `PollGroupEngine.Subscribe` / `DriverHost.RegisterAsync` still race `DisposeAsync` — **Low** *(unchanged; third consecutive review)*
|
||
|
||
Unchanged from both prior reviews (`PollGroupEngine.cs:156–167` vs `:438–457`;
|
||
`DriverHost.cs:58–63` vs `:92–106`). Still a shutdown-window race, still cheap to close with a
|
||
`_disposed` flag checked before insert. Since S-2's fix added new teardown surface
|
||
(`UnsubscribeAsync`), closing this while the file is warm would be natural.
|
||
|
||
**Positive observations.** This window's stability engineering in this domain is the strongest of the
|
||
three review rounds. The surgical-deploys work is a model of default-closed design: the classifier's
|
||
`with`-equality whitelists close over future fields; every surgical failure ratchets one-way to a
|
||
full rebuild; the degraded-apply no-advance rule (`OpcUaPublishActor.cs:511–544`) *explicitly
|
||
documents the accidental self-healing it replaces and the re-remove/ratchet cost of the replacement* —
|
||
honesty about failure-path economics that most codebases never write down. The `Open`-vs-lenient
|
||
split on the apply path (`:465–474`), the #485 guards, the fire-and-forget provisioning hook's
|
||
faulted-task observation (`AddressSpaceApplier.cs:624–649`), and the R2-02 resilience clamps
|
||
(`ClampPolicy` with per-field diagnostics) are all correct. The Cluster bootstrap guard makes its
|
||
join decision from an explicit reachability signal *before* the handshake — the precise lesson of the
|
||
deleted `SelfFormAfter`, applied.
|
||
|
||
### 2. Performance
|
||
|
||
#### P-2 — Authorization-walk allocations: mitigated by the decision cache, unresolved at the trie — **Low** *(downgraded from Medium)*
|
||
|
||
`PermissionTrie.CollectMatches` still allocates a `HashSet` + `List` per walk
|
||
(`PermissionTrie.cs:41–44`), but the stage-6 hot seams now consult the per-(session, node, op, realm)
|
||
`SessionAclDecisionCache` first, and `UserAuthorizationState` is built only on a miss. Write, alarm
|
||
commands, and HistoryRead still walk per request — acceptable rates. Residual work: hoist the
|
||
principal set per session generation.
|
||
|
||
#### P-3 — `PollGroupEngine`: one task per subscription, no cross-subscription coalescing — **Medium** *(unchanged)*
|
||
|
||
Still `Task.Run` per `Subscribe` (`PollGroupEngine.cs:165`). The new pacing/overrun/batch work
|
||
improved per-loop behavior without touching the scaling shape: hundreds of subscriptions per driver
|
||
instance still mean hundreds of independent loops and duplicate protocol reads on overlapping tag
|
||
sets.
|
||
|
||
#### P-5 — UNS-reference composition re-parses the backing tag's TagConfig per reference — **Low** *(new; the residue of P-1)*
|
||
|
||
`BuildUnsReferenceVariables` calls `TagConfigIntent.Parse(tag.TagConfig)` per reference row
|
||
(`AddressSpaceComposer.cs:842`) after `BuildRawSubtree` already parsed the same blob (`:769`). N
|
||
references to one tag = N+1 parses. A per-compose `Dictionary<TagId, TagConfigIntent>` closes it.
|
||
Compose-time only; cosmetic at current fleet sizes.
|
||
|
||
**Positive observations.** This window delivered real throughput work in-scope: non-rebuild applies
|
||
materialise the **delta** (O(change) vs O(address space) — the 50k-sink-calls-for-one-tag case is
|
||
gone, `OpcUaPublishActor.cs:594–603`), the artifact is parsed once per apply
|
||
(`ParsedDeploymentArtifact`), batched `WriteValues` lands under one node-manager lock with a typed
|
||
partial-failure contract (`IOpcUaAddressSpaceSink.cs:193–205`), struct realm keys replaced formatted
|
||
NodeId strings (`4f177bc3`), and the artifact itself is now gzip-compressed (~10–20× smaller through
|
||
SQL, the cache, and the fetch stream) with an explicit "hash covers the compressed bytes" invariant
|
||
test. The ACL hot path gained a lookup-before-scope-build ordering and per-key cache shards.
|
||
|
||
### 3. Conventions
|
||
|
||
#### C-9 — Role normalization is inconsistent across the four Cluster fail-fast validators: three of them are case-sensitive over verbatim `Cluster:Roles` — **Medium** *(new)*
|
||
|
||
`SplitTopologyTransportValidator` normalizes roles (trim + lower) and its comment names the exact
|
||
hazard: "the `Cluster:Roles` bind path is verbatim (no lowercasing)… so 'Driver' / 'Admin' cannot
|
||
slip past the ordinal checks" (`SplitTopologyTransportValidator.cs:69–78`). Its three siblings do the
|
||
opposite: `MeshTransportOptionsValidator.cs:76–79`, `TelemetryOptions.cs:124–126`, and
|
||
`ConfigSourceOptionsValidator.cs:101–104` all run case-sensitive `Array.IndexOf(roles,
|
||
RoleParser.Driver)` over `ResolveEffectiveRoles`, which returns configured roles **verbatim**
|
||
(`RoleParser.cs:115–118` — only the env fallback is lowercased/validated by `Parse`). A node
|
||
configured `Cluster:Roles: ["Driver"]` silently skips every driver-scoped boot-time rule
|
||
(GrpcPsk-required, CentralGrpcEndpoints-required, FetchAndCache-required) and fails later at runtime
|
||
with precisely the absence-class failures these validators exist to fail fast on. Nothing validates
|
||
unknown/miscased plain role names on the configured path at all. Fix: normalize (and
|
||
`Allowed`-validate) inside `ResolveEffectiveRoles` itself so every consumer inherits it — one
|
||
authority, not four re-derivations; this is the same rule the authoring-vs-deploy-gate lesson already
|
||
established elsewhere in this codebase.
|
||
|
||
#### C-7 — Stale/contradictory class docs — **Low** *(carried; contents updated)*
|
||
|
||
Current inventory: (a) `AddressSpaceApplier.cs:26–29` — the class doc simultaneously describes the
|
||
shipped PureRemove/AddRemoveMix behavior and says each "maps to a full rebuild until Phase 2/3
|
||
ships"; (b) `DriverHost.cs:7–9` — the named-pipe "Phase 2" promise, now three reviews old, describing
|
||
an architecture superseded by the out-of-repo gateways; (c) #522 tombstones written in the present
|
||
tense: `DriverTier.cs:18` ("Process-kill protections (MemoryRecycle, ScheduledRecycleScheduler)
|
||
are…") and `DriverFactoryBootstrap.cs:179`; (d) `WedgeDetector.cs:65–76`'s `BulkheadDepth` parameter
|
||
name outliving the bulkhead deletion. Individually trivial; collectively they erode the one thing
|
||
this codebase's documentation is otherwise excellent at — being trustworthy.
|
||
|
||
#### C-8 — The deferred-sink forwarding guard's interface detection is still namespace-filtered — **Low** *(unchanged)*
|
||
|
||
`DeferredSinkForwardingReflectionTests.cs:44, 106`. Unchanged verdict and unchanged recommendation
|
||
(exclude a known-interface allowlist instead of filtering by namespace). Worth noting the guard
|
||
earned its keep this window: every new sink member (surgical removes, `WireAlarmNotifiers`, batched
|
||
`WriteValues`) is forwarded, with the batch overload explicitly overridden so the DIM per-item
|
||
fallback cannot silently discard the one-lock-per-batch win (`DeferredAddressSpaceSink.cs:30–33`).
|
||
|
||
#### C-10 — `CsvParser` does not strip a UTF-8 BOM — **Low** *(new)*
|
||
|
||
A U+FEFF-prefixed document makes the first header column `"Name"`, and `ParseWithHeader`
|
||
(`Commons/Csv/CsvParser.cs:259–292`) silently fails to key that column. CSV exported from Excel — the
|
||
overwhelmingly likely source for the raw-tree import this parser serves — is BOM-prefixed by default
|
||
when saved as "CSV UTF-8". One `TrimStart('')` on the first field of the first record.
|
||
|
||
#### C-11 — Applier's terminal-write failure tally is inconsistent across the remove pass — **Low** *(new)*
|
||
|
||
The UNS-reference remove-previous counts a failed terminal Bad write into `FailedNodes`
|
||
(`AddressSpaceApplier.cs:300`), and the condition-state writes count (`:102, :107, :374, :430`), but
|
||
the plain-variable terminal writes discard the bool (`:380, :389, :419, :435`). Either posture is
|
||
defensible (a failed terminal write before a successful remove is arguably benign); holding both at
|
||
once means the counter's meaning depends on node kind. Pick one.
|
||
|
||
#### C-12 — `ConfigComposer` snapshots whole EF entities: a future sensitive column ships fleet-wide automatically — **Low** *(new)*
|
||
|
||
`SnapshotAndFlattenAsync` serializes entire entity rows for 17 tables (`ConfigComposer.cs:39–64`).
|
||
The `ApiKeys` inclusion was handled with care (peppered HMAC only, pepper deliberately out of the
|
||
artifact — `:54–59`), which proves the risk is understood — but the protection is per-column
|
||
vigilance at authoring time, not a mechanism. Any future secret-bearing column on any snapshotted
|
||
entity reaches every driver node's LocalDb cache and the gRPC fetch stream by default. A
|
||
convention-level guard (a test asserting no property name matching `*Secret*`/`*Password*`/`*Key*`
|
||
outside an allowlist appears in the serialized shape) would make the posture mechanical.
|
||
|
||
**Positive observations.** The convention-enforcement trajectory continues upward: `TagConfigIntent`
|
||
killed the four-copy byte-parity contract *and* the corpus parity test makes the remaining two seams
|
||
(entity-side compose, JSON-side decode) mechanically checked; `V3NodeIds` + `RawPaths` are genuine
|
||
single identity authorities (17 consumer files, validation at every authoring seam), and the
|
||
`DraftValidator` rework consumed them instead of re-deriving (closing C-3); the mesh DTO mapper
|
||
throws on unset oneofs/UNSPECIFIED enums with the anti-silent-drop rationale documented
|
||
(`MeshTransportDtoMapper.cs:210, 242, 342, 369`); and `ISkippedTagReporter` encodes "null = no
|
||
answer, never a zero report" in its return type with a stage/commit ledger
|
||
(`ISkippedTagReporter.cs:47–55, 128–174`) — the #485 lesson generalized into a contract shape.
|
||
|
||
### 4. Underdeveloped Areas
|
||
|
||
#### U-1 — Dead code retained in Core: `GenericDriverNodeManager`, `EquipmentNodeWalker`, `TryParseRelayBody` — **Medium** *(unchanged; third consecutive review)*
|
||
|
||
All three survive with zero production references (`Core/OpcUa/GenericDriverNodeManager.cs`,
|
||
`Core/OpcUa/EquipmentNodeWalker.cs` + `IdentificationFolderBuilder`,
|
||
`EquipmentScriptPaths.cs:228`). The counter-argument died this window: #522 demonstrated the team
|
||
will delete dormant machinery wholesale when it decides to, and the v3 rework retired
|
||
`EquipmentNodeIds` cleanly — this trio is now the *only* dormant surface left in the domain. Delete
|
||
it (git preserves it), or move the walker into the test project.
|
||
|
||
#### U-7 — `RawPathResolver` returns a truncated path as success when the parent chain cycles — **Low** *(new)*
|
||
|
||
`BuildFolderChain`/`AppendGroupChain` (`Commons/Types/RawPathResolver.cs:100–113, 117–133`) exit the
|
||
depth-256 loop with `current != null` and return the truncated 256-segment chain as a *valid*
|
||
RawPath, where the class doc promises a broken chain "degrades to 'this tag has no RawPath'
|
||
(dropped)". Requires DB/artifact corruption to reach (the authoring paths prevent cycles), but a
|
||
wrong NodeId identity is strictly worse than a dropped tag — this is the optimistic-success shape in
|
||
the codebase's single identity authority. Return null when the loop exhausts with `current != null`.
|
||
|
||
#### U-8 — Surgically-added RAW nodes are materialised but never announced — the announce path is Uns-only — **Medium** *(new; known and documented, still open)*
|
||
|
||
`AnnounceAddedNodes` hardcodes `AddressSpaceRealm.Uns` (`AddressSpaceApplier.cs:572–579`) and
|
||
`ComputeAddAnnouncements` collects only UNS parents (`:515–536`); the publish actor's own comment
|
||
states the consequence precisely: before surgical deploys a raw add forced a full rebuild (clients
|
||
had to re-browse anyway), so the missing announcement was neutral — now a surgically-added raw
|
||
container/tag is served but **genuinely invisible** to model-aware clients until they happen to
|
||
re-browse ("a real follow-up, not an accepted limitation", `OpcUaPublishActor.cs:555–563`). The delta
|
||
work changed this from cosmetic to a functional gap in the feature's own headline scenario (add one
|
||
tag without disturbing anyone). Needs a realm-aware `RaiseNodesAddedModelChange`.
|
||
|
||
#### U-9 — Bootstrap-guard operability edges: founder election assumes identical host spelling; the peer-first hang warning is one-shot and log-only — **Low** *(new)*
|
||
|
||
(a) `ClusterBootstrapGuard.Analyze` orders `host:port` strings lexicographically from each node's own
|
||
seed list (`ClusterBootstrapGuard.cs:112–115`); if one pair node's config spells hosts as DNS names
|
||
and the other's as IPs, both can elect themselves founder — recreating the split the guard exists to
|
||
close, undetectably. A boot-time log of the computed ordering (or a validator asserting the pair's
|
||
lists are element-wise equal-modulo-self) would surface it. (b) The documented residual hang (founder
|
||
dies inside the probe→join window) warns exactly once (`ClusterBootstrapCoordinator.cs:142–158`) and
|
||
never reaches a health surface or ServiceLevel — a node can sit unjoined for hours emitting nothing
|
||
after the first line.
|
||
|
||
#### U-4 — Additive-only ACL model (no Deny) — **Low, rising** *(unchanged code, changed stakes)*
|
||
|
||
`PermissionTrie.cs:13–15` unchanged — but since #520 the trie now gates the *entire* data plane
|
||
(read/browse/subscribe/write/alarms/HistoryRead), not just two role gates. "A broad grant cannot be
|
||
carved back at a sub-scope" is now an operational security constraint operators will meet, not a
|
||
latent design note. Keep it prominent in `docs/security.md`; revisit when the first
|
||
carve-out request arrives.
|
||
|
||
---
|
||
|
||
## Maturity Ratings
|
||
|
||
| Dimension | 2026-07-08 | 2026-07-12 | 2026-08-19 | Justification |
|
||
|---|---|---|---|---|
|
||
| Stability | 4 | 4 | **4.5** | The domain's headline open item (S-1) finally got its failure surface — built, consumed, metered, tested, and paired with a designed repair path (degraded-apply no-advance + rebuild ratchet) whose trade-offs are documented in place. S-2/S-4/S-6/S-7/S-8 all closed with real code. The surgical-deploys pipeline is default-closed at every layer. What keeps it off 5: the ack is still decoupled from the apply outcome (S-9), the malformed-artifact leg of #485 survives on the boot paths with gzip widening it (S-10), and S-3 is now three reviews old. |
|
||
| Performance | 3 | 3 | **4** | Two of four prior findings closed (P-1, P-4) and the window shipped genuine throughput work in-scope: O(change) delta applies, once-per-apply artifact parse, batched WriteValues, struct realm keys, gzip artifacts, ACL decision caching. P-3's task-per-subscription shape and the trie-walk allocations (now miss-only) are the remaining ceilings. |
|
||
| Conventions | 4 | 4 | **4** | The systemic byte-parity duplication — the domain's worst conventions debt for three rounds — is gone (C-1/C-3 closed via `TagConfigIntent` + shared identity authorities, with a corpus parity test). But the window also *produced* a fresh instance of the same disease (C-9: one normalization rule stated in one validator, violated by its three siblings), the applier's rewritten doc contradicts itself, and C-4/C-5/C-6/C-8 survived a third review unchanged. High and holding, not rising. |
|
||
| Underdeveloped areas | 3 | 3 | **4** | The authored-but-inert backlog was cleared the right way: U-2 and U-6 deleted outright (#522/R2-02) rather than propped up, U-3 and U-5 closed with tests, and the forwarding guard caught real new members this window. Remaining: the U-1 trio (now the only dormant surface left, and inexcusable post-#522), the Uns-only announce gap that surgical deploys promoted from cosmetic to functional (U-8), and small optimistic-success edges in new code (U-7, U-9). |
|