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.
37 KiB
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/RawPathsidentity 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 consumersOpcUaPublishActorandDeploymentArtifact, andControlPlane/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
-
AddressSpaceComposer.Compose(OpcUaServer/AddressSpaceComposer.cs) — pure static projection from EF entities into anAddressSpaceComposition. v3 reshaped it around the dual namespace: it emits the Raw subtree (RawContainerNodeFolder→Driver→Device→TagGroup +RawTagPlankeyed by RawPath,:708–794) and the UNS projection (areas/lines/equipment +UnsReferenceVariablemirror records,:803–860), all ordinally sorted. Tag intent parses once per tag through the CommonsTagConfigIntentauthority (:769). The critical 2026-08-15 design decision lives in the record shape:UnsReferenceVariablecarriesBacking{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. -
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. -
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 threewith-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). -
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/RemovedConditionStatewrites (ApplyPureRemove:360–446), and the one-way rebuild ratchet on any surgicalfalse/throw (:312). A degraded outcome is now first-class (AddressSpaceApplyOutcome.RebuildFailed/FailedNodes). The fiveMaterialise*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 — whileMaterialiseHierarchydeliberately stays full-composition (ancestors must exist;:767–774).OpcUaPublishActorholds 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). |