Files
lmxopcua/archreview/01-core-composition.md
T
Joseph Doherty 44f6408e1c docs(archreview): round-3 re-review at 12b0035a — 7 domain reports + 00-OVERALL rewritten in place, STATUS.md round-3 section
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.
2026-08-20 09:09:46 -04:00

37 KiB
Raw Blame History

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).