611 lines
73 KiB
Markdown
611 lines
73 KiB
Markdown
# Design & Implementation Plan — Server & Runtime Subsystem (Report 03)
|
||
|
||
> **Status (2026-07-08):** ✅ **S1 (Critical) typed-option DONE** — branch `fix/archreview-crit1-split-brain-resolver`
|
||
> @ `a25c9ed0` (typed `KeepOldestOption`, unit guards + e2e config-resolution verified). ✅ **U1 doc DONE**
|
||
> (CLAUDE.md KNOWN LIMITATION 2 corrected). ✅ **S1 hard-kill failover integration test DONE** (task 9, same branch
|
||
> `a25c9ed0`) — `HardKillFailoverTests` LIVE-verified in-process + negative-control-proven. ⚠️ **that negative
|
||
> control surfaced task #11: SBR was ALREADY active on master** (Akka.Cluster.Hosting default + pre-existing
|
||
> akka.conf keep-oldest), so Crit-1's "was NoDowning" premise is inaccurate — correct the rationale/docs.
|
||
> ✅ **U2 (reflection forwarding test) DONE** — branch `fix/archreview-u2-deferred-sink-forwarding-guard`
|
||
> @ `a65c2ced` (DispatchProxy-based exhaustive forwarding guard over `IOpcUaAddressSpaceSink` +
|
||
> `ISurgicalAddressSpaceSink` + `DeferredServiceLevelPublisher`; negative-control verified; the gate for P1/P2 is
|
||
> now in place). Remaining: P1, S2/S3/S4. See [`STATUS.md`](STATUS.md).
|
||
|
||
- **Source report:** `archreview/03-server-runtime.md`
|
||
- **Review commit:** `9cad9ed0`
|
||
- **Verification pass performed against tree at:** current `master` (same tree; all cited files present)
|
||
- **Scope:** `OpcUaServer`, `Host`, `Runtime`, `ControlPlane`, `Security`; redundancy, LDAP, node manager, address-space applier.
|
||
|
||
## Verification summary
|
||
|
||
| ID | Sev | Status | One-liner |
|
||
|----|-----|--------|-----------|
|
||
| S1 | Critical | **Confirmed** | SBR configured in HOCON but never activated → NoDowning; hard-crash failover broken |
|
||
| S2 | High | **Confirmed** | LDAP auth block-bridges session activation; no timeout, no pooling |
|
||
| S3 | High | **Confirmed** | HistoryRead block-bridges gateway per node, sequentially, on SDK threads |
|
||
| S4 | High | **Confirmed** | Primary write/ack gate defaults to allow while role unknown → dual-primary window |
|
||
| P1 | High | **Confirmed** | Any structural add full-rebuilds address space, kills all subscriptions |
|
||
| S5 | Medium | **Confirmed** | Redundancy NodeId string-matched across 3 sources, silent on mismatch |
|
||
| S6 | Medium | **Confirmed** | No `SupervisorStrategy` anywhere in Runtime; restart wipes subscription state, hot-loops |
|
||
| S7 | Medium | **Confirmed** | `DriverInstanceActor.PostStop` block-bridges `ShutdownAsync(None)` |
|
||
| S8 | Medium | **Confirmed** | Inbound Part 9 / native-ack routes are at-most-once fire-and-forget, no metric |
|
||
| S9 | Medium | **Confirmed** | Server cert 12-month lifetime, boot-only check, no expiry monitoring |
|
||
| S10 | Low | **Confirmed** | SDK start failure swallowed; no health surface |
|
||
| S11 | Low | **Confirmed** | `AuditWriterActor` unbounded buffer, drops batches (moot — no producers, U3) |
|
||
| P2 | Medium | **Confirmed** | Per-value global-lock write, one actor msg per value, no batching |
|
||
| P3 | Medium | **Confirmed** | HistoryRead-Events unbounded (`NumValuesPerNode==0 → int.MaxValue`), no paging |
|
||
| P4 | Low | **Confirmed** | Every deploy re-runs 4 Materialise passes over full composition |
|
||
| C1 | Medium | **Confirmed** | `ServerHistorian` bound imperatively in 5 places, no `IOptions` |
|
||
| C2 | Medium | **Confirmed** | Two-tier options validation; `DevStubMode` only log-warned in prod |
|
||
| C3 | Low | **Confirmed** | Library code logs via static Serilog; node manager uses obsolete `Utils.LogError` |
|
||
| C4 | Low | **Confirmed** | `IHistorianProvisioning` resolve has no missing-registration warning |
|
||
| U1 | High (doc) | **Confirmed stale doc** | CLAUDE.md "Known Limitation 2" stale — recorder ref-feed IS wired |
|
||
| U2 | Medium | **Confirmed** | Deferred-sink forwarding correct today but only ~5/10 members test-guarded |
|
||
| U3 | Medium | **Confirmed** | Audit pipeline built/tested, zero producers |
|
||
| U4 | Medium | **Confirmed** | `FleetStatusBroadcaster.DriverHostStatusHeartbeat` dead code, host-only key |
|
||
| U5 | Medium | **Confirmed** | Native Part 9 conditions: Acknowledge-to-driver only; Confirm/Shelve/Enable no-op upstream |
|
||
| U6 | Medium | **Confirmed** | Test gaps concentrate on DPS delivery / supervision / hard-kill failover |
|
||
| U7 | Low | **Confirmed** | `IHistoryWriter` Null-wired; H2 HistoryUpdate unimplemented; enum gap |
|
||
| U8 | Low | **Confirmed** | `BuildSecurityPolicies` doc claims a log it doesn't emit |
|
||
| U9 | Low | **Confirmed** | `EnsureVariable` silently ignores changed historize-intent (invariant in comments) |
|
||
|
||
**Nothing in this report is stale-because-already-fixed.** The one "stale" item (U1) is stale *documentation* — the code it describes as broken is actually wired. Every other finding reproduces against the current tree.
|
||
|
||
**Top 3 by priority:** (1) **S1** — activate the split-brain resolver (OVERALL action #1); (2) **P1** — surgical pure-adds in the address-space applier (OVERALL action #8, highest operational perf win); (3) **S4 + S2/S3** — the availability/blocking cluster (primary-gate default-deny, async LDAP, channelized HistoryRead; OVERALL actions #7/#9).
|
||
|
||
---
|
||
|
||
## S1 — CRITICAL — Split-brain resolver configured but never activated
|
||
|
||
**Restatement:** `akka.conf` carries a `split-brain-resolver { active-strategy = "keep-oldest" … }` block but nothing registers a downing provider, so the cluster runs Akka's default **NoDowning**. Hard-crashed nodes stay unreachable forever → singletons + `driver` role-leader never fail over; a partition leaves both sides at ServiceLevel 240.
|
||
|
||
**Verification — Confirmed.**
|
||
- `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf:40-46` — the SBR block is present, inside `akka.cluster { … }`.
|
||
- `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs:80-84` — `WithClustering(new ClusterOptions { SeedNodes = …, Roles = … })` sets **only** those two properties; `SplitBrainResolver` is never assigned.
|
||
- Repo-wide grep for `downing-provider|SplitBrainResolver|KeepOldest|downing-provider-class` returns **zero** hits (confirmed empty).
|
||
- Packages: `Akka.Cluster` / `Akka.Cluster.Hosting` = **1.5.62** (`Directory.Packages.props:6-13`). This version ships `Akka.Cluster.SBR.SplitBrainResolverProvider` and the `Akka.Cluster.Hosting` `ClusterOptions.SplitBrainResolver` option surface.
|
||
- `min-nr-of-members = 1` (`akka.conf:38`) makes each partition side self-sufficient for leader election → the "both sides at 240" partition case is real.
|
||
|
||
**Root cause:** In Akka.NET the `split-brain-resolver` HOCON block is *inert configuration* — it only takes effect when `akka.cluster.downing-provider-class` points at `Akka.Cluster.SBR.SplitBrainResolverProvider` (or the Hosting `ClusterOptions.SplitBrainResolver` option, which sets that provider under the hood). The HOCON block was written but the activating registration was never added. Classic "config-in-HOCON is not config-in-effect" (OVERALL cross-cutting theme #1).
|
||
|
||
**Proposed design.** Prefer the **Akka.Hosting typed option** (`ClusterOptions.SplitBrainResolver`) over hand-writing `downing-provider-class`, because the rest of the cluster bootstrap already goes through `WithClustering(ClusterOptions)` and Akka.Hosting owns provider wiring — mixing a raw HOCON `downing-provider-class` with Hosting risks the same "Hosting doesn't honor HOCON" foot-gun already documented for `akka.loggers` (`akka.conf:9-13`). Keep the HOCON block as the tuning source (`stable-after`, `keep-oldest.down-if-alone`) — the SBR provider reads those keys once activated — OR move the strategy fully into the typed option and delete the HOCON block to avoid a two-source drift. **Recommendation: use the typed option and keep HOCON only for values the typed option can't express, documenting which wins.**
|
||
|
||
Strategy choice: **keep-oldest with `down-if-alone = on`** matches the current HOCON intent and is correct for a 2-node warm-redundancy pair (the oldest — typically the long-running primary — survives an even split; `down-if-alone` downs a singleton that loses quorum). `keep-majority` is wrong for 2 nodes (no majority in a 1-1 split). `static-quorum` would need `quorum-size = 2` which defeats single-node deploys. Keep-oldest is the right call; document the reasoning.
|
||
|
||
`stable-after = 15s` must be ≥ the failure detector's `acceptable-heartbeat-pause` (10s) plus margin — current 15s is fine but tie it explicitly to `down-removal-margin` (also 15s) in a comment.
|
||
|
||
**Implementation steps.**
|
||
1. `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs` — in `WithOtOpcUaClusterBootstrap`, set the SBR option on the `ClusterOptions`:
|
||
```csharp
|
||
builder.WithClustering(new ClusterOptions
|
||
{
|
||
SeedNodes = options.SeedNodes,
|
||
Roles = options.Roles,
|
||
SplitBrainResolver = new KeepOldestOption { DownIfAlone = true }, // Akka.Cluster.Hosting
|
||
});
|
||
```
|
||
(Confirm the exact type name against Akka.Cluster.Hosting 1.5.62 — it is `KeepOldestOption : SplitBrainResolverOption`, with `DownIfAlone` and optional `Role`. `StableAfter` is set on the option or left to the HOCON default.)
|
||
2. Add a **startup assertion log line** proving SBR is active (OVERALL theme #1 recommendation). In the same method after `WithClustering`, register a small `IActorRef`-less startup hook (or log in `WithOtOpcUaClusterBootstrap`) that reads `Context.System.Settings.Config.GetString("akka.cluster.downing-provider-class")` at first `MemberUp` and logs `Info` if it equals the SBR provider, `Error` if empty. A cheap version: a tiny actor subscribed to `ClusterEvent.IMemberEvent` that logs the resolved downing provider once. This converts the silent-inert failure mode into an operator-visible signal.
|
||
3. Reconcile the HOCON: either delete the `split-brain-resolver` block from `akka.conf` (typed option now owns it) OR keep it and add a comment that `WithClustering(SplitBrainResolver=…)` is what activates it. Do not leave both un-cross-referenced.
|
||
4. Update `docs/Redundancy.md` and `docs/v2/Architecture.md` — document that SBR is now active, the keep-oldest rationale, and the hard-crash failover expectation.
|
||
|
||
**Tests.**
|
||
- **Unit:** a config test asserting `akka.cluster.downing-provider-class == "Akka.Cluster.SBR.SplitBrainResolverProvider, Akka.Cluster"` after `WithOtOpcUaClusterBootstrap` runs (proves the typed option activated the provider). This is the "assert SBR is active" guard.
|
||
- **Integration (the load-bearing one):** extend the 2-node harness (`tests/Server/.../TwoNodeClusterHarness`) with a **hard-kill failover test**: bring both nodes up, confirm role-leader on node A, `kill -9`-equivalent (abrupt `ActorSystem` terminate / drop the transport without `CoordinatedShutdown`), then assert within `stable-after + margin` that (a) node B becomes `driver` role-leader, (b) the singletons (`RedundancyStateActor`, `ConfigPublishCoordinator`, `AdminOperationsActor`, `AuditWriterActor`) hand over to B, (c) B's ServiceLevel rises to 240 and the (dead) A demotes. The current harness only exercises graceful `StopAsync` — this is exactly the U6 blind spot. If in-process abrupt-kill is hard to simulate deterministically, use `Cluster.Get(sys).Down(selfAddress)` suppression + transport fault injection, or run it as a nightly docker-host multi-process test.
|
||
- **Partition test (optional, nightly):** simulate a 1-1 partition (block the transport between the two), assert exactly one side downs the other and only one side keeps 240.
|
||
|
||
**Effort:** S (the fix) + M (the failover test harness). **Risk/blast-radius:** Medium. The one-line option change alters cluster downing behavior globally — a mis-tuned `stable-after` could down a node during a transient GC pause. Mitigate by keeping `stable-after ≥ acceptable-heartbeat-pause + margin` and validating on the 2-node rig before merge.
|
||
|
||
---
|
||
|
||
## P1 — HIGH — Structural deploy full-rebuilds the address space, severing every client subscription
|
||
|
||
**Restatement:** `AddressSpaceApplier.Apply` forces `RebuildAddressSpace()` for any added/removed/structurally-changed equipment, alarm, tag, or virtual tag. The rebuild removes and recreates every `NodeState`, so all existing client monitored items go dead server-wide. Adding one tag to one equipment drops every subscription on the server.
|
||
|
||
**Verification — Confirmed.**
|
||
- `AddressSpaceApplier.cs:134-140` — `structuralRebuild` is true whenever `AddedEquipmentTags.Count > 0 || RemovedEquipmentTags.Count > 0 || AddedEquipment/AddedAlarms/… > 0`. Pure adds fall into this branch.
|
||
- `AddressSpaceApplier.cs:150-154` — `if (structuralRebuild) { SafeRebuild(); rebuilt = true; }`.
|
||
- `OtOpcUaNodeManager.cs:1690-1736` — `RebuildAddressSpace()` clears `_variables`, `_alarmConditions`, `_folders`, `_notifierFolders`, `_eventNotifierSources` under one `Lock`, detaching/removing every `NodeState`.
|
||
- `OpcUaPublishActor.cs:338-354` — after `Apply`, the actor *unconditionally* re-runs the four `Materialise*` passes, which recreate all nodes from scratch. Existing subscriptions bind to the old (now-removed) `NodeState` instances → dead.
|
||
- The surgical path (`AddressSpaceApplier.cs:155-199`) covers only **changed** tags (`TagDeltaIsSurgicalEligible`) and folder renames — **not adds or removes**.
|
||
- Building blocks already exist and are idempotent: `EnsureFolder` (`:1284`), `EnsureVariable` (`:1369`, early-returns on existing id — U9 confirms), `MaterialiseAlarmCondition` (`:586`, idempotent guard `:57`), and `RaiseNodesAddedModelChange` (`:1592`).
|
||
|
||
**Root cause:** The applier's rebuild predicate treats "add" and "remove" identically to "structural change." But a pure add needs no teardown at all — the idempotent `EnsureFolder`/`EnsureVariable`/`MaterialiseAlarmCondition` passes add exactly the new nodes and no-op the existing ones; `RaiseNodesAddedModelChange` already exists to announce them to subscribed clients. The rebuild is unnecessary for the pure-add case and catastrophic for live subscriptions.
|
||
|
||
**Proposed design — surgical pure-adds (phase 1), scoped removals (phase 2).** This is the highest-leverage item in the subsystem (OVERALL action #8) and the report's own top recommendation. Do it in two phases so phase 1 (adds) ships value fast and low-risk.
|
||
|
||
**Phase 1 — pure-add surgical path.** Split the rebuild predicate: a rebuild is only *required* when there is a **removal** or a **node-affecting change** (the existing non-surgical-eligible change set). Pure adds route to the idempotent Materialise passes + a model-change announcement, no teardown:
|
||
- New predicate:
|
||
- `requiresRebuild = RemovedEquipment/RemovedAlarms/RemovedEquipmentTags/RemovedEquipmentVirtualTags any > 0` **OR** `ChangedEquipment.Count > 0` **OR** `ChangedAlarms.Count > 0` **OR** `ChangedEquipmentTags.Any(!surgicalEligible)` **OR** `ChangedEquipmentVirtualTags.Any(!nodeIrrelevant)`.
|
||
- `pureAdds = !requiresRebuild && (AddedEquipment/AddedAlarms/AddedEquipmentTags/AddedEquipmentVirtualTags any > 0)`.
|
||
- When `pureAdds` (and not requiresRebuild): **do not** call `RebuildAddressSpace()`. The existing unconditional Materialise passes in `OpcUaPublishActor` already add only the new nodes (idempotent). Add a step that calls `RaiseNodesAddedModelChange` for each newly-added node/folder so subscribed clients get a `GeneralModelChangeEvent` and can re-browse.
|
||
- Because `OpcUaPublishActor` currently re-runs the Materialise passes regardless, the safest minimal change is: in `Apply`, compute `rebuilt=false` for the pure-add case and rely on the downstream passes; then emit model-change events for the plan's added ids. Confirm the Materialise passes run *after* `Apply` returns (they do — `OpcUaPublishActor.cs:335-354`), so the added nodes exist before the announce; if announce must follow materialise, move the announce into the applier's post-materialise hook or have `OpcUaPublishActor` call a new `applier.AnnounceAdds(plan)` after the passes.
|
||
|
||
**Phase 2 — scoped per-equipment removal.** For removals, add surgical `RemoveVariable(nodeId)` / `RemoveFolder(nodeId)` / `RemoveAlarmCondition(nodeId)` methods to the node manager (mirroring the per-node teardown already inside `RebuildAddressSpace`'s loops but scoped to one id), forwarded through `ISurgicalAddressSpaceSink` + `DeferredAddressSpaceSink` (respect the U2 forwarding trap — every new capability method MUST be added to the deferred wrapper and test-guarded). A remove of equipment E only tears down E's subtree, preserving every other equipment's subscriptions. Announce via `RaiseNodesDeletedModelChange` (add if the SDK exposes it; else a `GeneralModelChangeEvent`). Only fall back to a full rebuild for the genuinely-ambiguous changed-topology cases.
|
||
|
||
**Alternatives considered:** (a) Leave rebuild but *re-attach* subscriptions to new nodes — not feasible; the SDK binds monitored items to `NodeState` instances, and there's no supported re-home API. (b) Diff-and-patch every deploy generically — larger and riskier than the add/remove split; the plan already carries typed add/remove/change deltas, so use them. The add/remove split reuses the exact pattern F10b established for surgical changes.
|
||
|
||
**Implementation steps.**
|
||
1. `AddressSpaceApplier.cs` — refactor the rebuild predicate into `requiresRebuild` / `pureAdds`; route pure adds away from `SafeRebuild()`. Add `AnnounceAdds(AddressSpacePlan)` calling `_sink.RaiseNodesAddedModelChange(id)` for each added folder/variable/alarm node id (compute ids via `EquipmentNodeIds` as the surgical change path already does at `:177`).
|
||
2. `OtOpcUaNodeManager.cs` — Phase 2: add `RemoveVariable`/`RemoveFolder`/`RemoveAlarmCondition` (scoped teardown under `Lock`, drop the matching `_historizedTagnames`/`_eventNotifierSources`/`_nativeAlarmNodeIds` registrations); add `RaiseNodesDeletedModelChange` if available.
|
||
3. `ISurgicalAddressSpaceSink` (+ `SdkAddressSpaceSink`, `NullAddressSpaceSink`, `DeferredAddressSpaceSink`) — Phase 2: add the remove methods with capability-sniffing forwarding, matching the existing `UpdateTagAttributes`/`UpdateFolderDisplayName` pattern (`DeferredAddressSpaceSink.cs:58-69`).
|
||
4. `OpcUaPublishActor.cs` — ensure the announce runs after the Materialise passes (call `_applier.AnnounceAdds(plan)` after line 354, or fold the announce into `Apply` if ordering allows).
|
||
|
||
**Tests.**
|
||
- **Unit:** applier test — a pure-add plan (one added tag) does **not** call `RebuildAddressSpace` (assert via a recording sink) and **does** call `RaiseNodesAddedModelChange` for the added id. A changed-only plan still routes surgical/rebuild as today. A remove plan (phase 2) calls `RemoveVariable` for the removed id and does not rebuild if no other change.
|
||
- **Live-verify (the decisive one — F10b/OpcUaServer-001 precedent proves unit-green is insufficient):** on docker-dev (rebuild both central-1/2), subscribe a Client.CLI monitored item to an existing equipment tag (`subscribe -n ns=2;s=…`), then `POST /api/deployments` a config that **adds one new tag** to a *different* equipment. Assert the CLI subscription keeps delivering (no `Bad`/dead notifications) and the new tag becomes browsable. This is the exact regression the whole item exists to kill. Reuse the OpcUaServer-001 live-verify recipe from memory (`project_full_codebase_review_2026-06-19.md`).
|
||
- **Integration:** `OpcUaServer.IntegrationTests` — over-the-wire subscribe-then-add-tag round-trip asserting subscription survival (also addresses U6's "one test only" gap).
|
||
|
||
**Effort:** L (phase 1 M, phase 2 M). **Risk/blast-radius:** High — this touches the address-space mutation core. Mitigate with the recording-sink unit tests + the mandatory subscription-survival live-verify + the U2 forwarding guard (a new remove capability that isn't forwarded through `DeferredAddressSpaceSink` ships inert, exactly like F10b).
|
||
|
||
---
|
||
|
||
## S4 — HIGH — Primary gate defaults to allow while role unknown (dual-primary data-plane window)
|
||
|
||
**Restatement:** `DriverHostActor`'s inbound-write / native-ack / native-alarm-emit gates gate on `_localRole is Secondary or Detached` and default to *allow* until the first redundancy snapshot arrives. A freshly-booted or snapshot-missed secondary services shared-field-device writes and emits alerts as primary for up to the 10s heartbeat interval — indefinitely if S5's NodeId mismatch bites.
|
||
|
||
**Verification — Confirmed.**
|
||
- `DriverHostActor.cs:195` — `private RedundancyRole? _localRole;` (nullable, **null at boot**).
|
||
- `HandleRouteNodeWrite` (`:1018-1026`): `if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached) { reject }` — null falls through to allow.
|
||
- `HandleRouteNativeAlarmAck` (`:1074-1083`): same pattern, drops on Secondary/Detached, null → allow.
|
||
- Native-alarm emit (`:956-984`): `if (_localRole is Secondary or Detached) continue;` — null → emit.
|
||
- `OnRedundancyStateChanged` (`:1109-1116`): a snapshot not mentioning this node leaves `_localRole` unchanged → stays null → default-allow.
|
||
|
||
**Root cause:** The boot-window default-allow is correct for single-node deploys (where no snapshot ever demotes) but wrong for multi-node clusters where a booting secondary has a real primary peer. The gate can't distinguish "single node, no peer" from "multi-node, snapshot not yet arrived." It uses the *role signal* to gate *shared-device writes*, and the role signal is under-determined at boot.
|
||
|
||
**Proposed design — cluster-membership-aware default.** Discover whether this is a multi-driver cluster from Akka cluster state (already available: `OpcUaPublishActor.cs:81` does `Akka.Cluster.Cluster.Get(Context.System)`). Change the gate default per cluster size:
|
||
- **Single-driver cluster** (≤1 member with the `driver` role): keep default-allow (preserves single-node deploys + boot window).
|
||
- **Multi-driver cluster** (≥2 `driver` members): **default-deny** the write/ack gates until at least one redundancy snapshot has been received (`_localRole is not null`). Native-alarm *condition writes* stay ungated (the secondary keeps its address space warm — that's `:953-954`, correct); only the *alerts publish* + *device write* + *device ack* gate deny-on-unknown.
|
||
|
||
Implementation: `DriverHostActor` subscribes to `ClusterEvent.IMemberEvent` (or reads `_cluster.State.Members` on demand) and tracks `driverMemberCount`. The gate becomes:
|
||
```csharp
|
||
bool servicedByThisNode = _localRole switch {
|
||
RedundancyRole.Secondary or RedundancyRole.Detached => false,
|
||
RedundancyRole.Primary => true,
|
||
null => DriverMemberCount() <= 1, // unknown: allow only on a single-node cluster
|
||
};
|
||
```
|
||
Add a `_hasReceivedSnapshot` flag (distinct from role, to handle the "snapshot arrived but didn't mention us" case → that's the S5 mismatch; log-once per S5 and deny on multi-node).
|
||
|
||
**Alternatives considered:** (a) Always default-deny — breaks single-node deploys (no snapshot ever arrives to enable writes). Rejected. (b) A fixed boot grace timer that flips to deny after N seconds — fragile, and a slow snapshot on a healthy multi-node cluster would wrongly deny. The membership-count approach is deterministic and uses signal already in hand. (c) Require a lease/fencing token — larger architectural change (the report notes "no lease or fencing token"); out of scope, membership-gate is the pragmatic fix.
|
||
|
||
**Implementation steps.**
|
||
1. `DriverHostActor.cs` — add `private readonly Akka.Cluster.Cluster _cluster = Akka.Cluster.Cluster.Get(Context.System);` (mirror `OpcUaPublishActor`); add `DriverMemberCount()` counting `Up` members carrying the `driver` role from `_cluster.State.Members`.
|
||
2. Introduce a single private `bool ShouldServiceAsPrimary()` helper encoding the switch above; call it from all three gate sites (`:960`, `:1022`, `:1078`) so the policy lives in one place.
|
||
3. On a snapshot that omits this node while `DriverMemberCount() > 1`, log-once `Warning` (this is the S5 hook — the two findings share the diagnostic).
|
||
4. Document the semantics in `docs/Redundancy.md`.
|
||
|
||
**Tests.**
|
||
- **Unit (TestKit):** with `_localRole == null` and a stubbed single-driver membership → write is serviced; with `_localRole == null` and a two-driver membership → write is rejected ("not primary / role unknown on multi-node"); after a Primary snapshot → serviced; after a Secondary snapshot → rejected.
|
||
- **Integration:** on the 2-node harness, boot the secondary and immediately issue a write before the first snapshot; assert it is rejected (not silently applied to the field device). This pairs with the S1 failover test.
|
||
|
||
**Effort:** S/M. **Risk/blast-radius:** Medium — changes write-admission policy on multi-node clusters; a bug could deny legitimate primary writes during a snapshot gap. Mitigate: the count check is conservative (only denies when a real peer exists) and the single-node path is untouched.
|
||
|
||
---
|
||
|
||
## S2 — HIGH — LDAP authentication block-bridges session activation with a non-cancellable, non-configurable timeout
|
||
|
||
**Restatement:** The SDK invokes `ImpersonateUser` synchronously; `OpcUaApplicationHost.HandleImpersonation` block-bridges the authenticator with `CancellationToken.None`. The authenticator adds no timeout; the shared library's connect timeout is a default that `LdapOptions.ToLibraryOptions()` neither projects nor exposes. Each auth opens a fresh TCP connect + service bind + search + user bind. A DC outage stalls SDK threads ~10-20s each.
|
||
|
||
**Verification — Confirmed.**
|
||
- `OpcUaApplicationHost.cs:271-273` — `authenticator.AuthenticateUserNameAsync(…, CancellationToken.None).GetAwaiter().GetResult();` on the SDK impersonation callback thread.
|
||
- `LdapOpcUaUserAuthenticator.cs:30-48` — awaits `ldap.AuthenticateAsync(username, password, ct)` with no timeout wrapper; the `ct` it receives from `HandleImpersonation` is `None`.
|
||
- `LdapOptions.cs:94-107` — `ToLibraryOptions()` projects Server/Port/Transport/… but **no timeout field**; `LdapOptions` has no `TimeoutMs`/`ConnectTimeout` property at all.
|
||
- No connection pooling: each `AuthenticateAsync` is a full connect+bind+search+bind cycle (shared-lib behavior, per report).
|
||
|
||
**Root cause:** The timeout responsibility was pushed to the authenticator (per `HandleImpersonation`'s doc comment) but no layer actually enforces one; the shared library's default connect timeout isn't surfaced or bounded per-call, and the call is synchronous under the SDK thread.
|
||
|
||
**Proposed design — hard timeout at the OtOpcUa boundary + surfaced option.** The clean fix lives in `LdapOpcUaUserAuthenticator` (OtOpcUa-owned; the shared library's sync-wrapped `AuthenticateAsync` can't be easily made cooperative). Add a `Task.WhenAny(auth, Task.Delay(timeout))` wrapper that **fails closed** (deny) on timeout, and surface `Security:Ldap:TimeoutMs` on `LdapOptions`. Consider a short **negative cache** (deny cache, e.g. 5-30s TTL keyed by username) to shed load during a sustained outage — during a DC-down storm, repeated activations for the same user return the cached deny instantly instead of each stalling for the full timeout.
|
||
|
||
Note: `Task.WhenAny` + `Task.Delay` doesn't *cancel* the underlying blocking LDAP call (it observes `CancellationToken.None` only at entry), so the orphaned auth task runs to completion in the background — but the SDK thread is released at `timeout`, which is the goal. Cap concurrent in-flight auths (a `SemaphoreSlim`) so an outage can't accumulate unbounded orphaned tasks.
|
||
|
||
**Alternatives considered:** (a) Make the shared library cooperatively cancellable — cross-repo change to `ZB.MOM.WW.Auth`, larger blast radius, and the report frames the fix as OtOpcUa-side. Track as a shared-lib follow-up but don't block on it. (b) Connection pooling in the library — desirable but out of scope for this repo; note it. The boundary timeout + negative cache is the contained, correct-fail-closed fix.
|
||
|
||
**Implementation steps.**
|
||
1. `LdapOptions.cs` — add `public int TimeoutMs { get; set; } = 10000;` (and optionally a `NegativeCacheSeconds`). Do **not** project into `ToLibraryOptions()` (it's an OtOpcUa-boundary concern) unless the library later exposes a matching field.
|
||
2. `LdapOpcUaUserAuthenticator.cs` — inject `IOptions<LdapOptions>` (or the timeout value); wrap the `ldap.AuthenticateAsync` call in a `Task.WhenAny(authTask, Task.Delay(TimeoutMs, ct))`; on timeout return `OpcUaUserAuthResult.Deny("Authentication timed out")` and log `Warning`. Bound concurrency with a `SemaphoreSlim(maxConcurrent)`. Add the optional negative cache (a `MemoryCache` or a small time-bucketed dictionary).
|
||
3. Optionally pass a real (timeout-derived) `CancellationToken` from `HandleImpersonation` instead of `None` so the entry-check short-circuits — but the authoritative bound is the `WhenAny`.
|
||
4. Add a validator note (C2): `TimeoutMs > 0`.
|
||
|
||
**Tests.**
|
||
- **Unit:** authenticator with a stub `ILdapAuthService` that delays beyond `TimeoutMs` → returns Deny within ~`TimeoutMs` (not the full delay); a fast success still succeeds; the negative cache returns a prior deny without re-hitting the stub within TTL; the semaphore bounds concurrency. The existing LDAP fail-closed tests (deny-on-error, opaque messages, zero-role fallback) must stay green.
|
||
- **Live/manual:** point `Security:Ldap:Server` at an unreachable host and drive a Client.CLI `connect` with a UserName token; confirm the activation fails fast (~TimeoutMs) rather than hanging ~10s, and that a burst of activations doesn't serialize.
|
||
|
||
**Effort:** M. **Risk/blast-radius:** Low-Medium — fail-closed on timeout preserves the existing deny-on-error posture; the risk is a too-short default timeout denying a legitimately-slow DC (default 10s matches the library default, so behavior is unchanged unless configured down).
|
||
|
||
---
|
||
|
||
## S3 — HIGH — HistoryRead block-bridges the gateway per node, sequentially, on SDK request threads
|
||
|
||
**Restatement:** All four HistoryRead arms call `.GetAwaiter().GetResult()` per node handle with `CancellationToken.None`, bounded only by the gateway client's 30s `CallTimeout`. A request naming N historized nodes against a slow historian holds one SDK request thread up to N × 30s; with `MaxRequestThreadCount=100`, a few misbehaving history clients can exhaust the request pool and degrade all OPC UA services.
|
||
|
||
**Verification — Confirmed.**
|
||
- `OtOpcUaNodeManager.cs:1795-1807` (`HistoryReadRawModified`), `:1823-1847` (`HistoryReadProcessed`), `:1858-1868` (`HistoryReadAtTime`), `:1886-1934` (`HistoryReadEvents`) — each iterates `nodesToProcess` in a **`foreach` (sequential)**.
|
||
- Block-bridge sites: `:2059` (`ServeNode`: `read(source, tagname).GetAwaiter().GetResult()`), `:2170-2171` and `:2202-2203` (Raw + tie-cluster overfetch), `:1902-1911` (Events) — all `CancellationToken.None`.
|
||
- `OpcUaApplicationHost.cs:329-331` — `MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200`.
|
||
- Comment at `:1777-1780` correctly notes the overrides run *outside* the node-manager `Lock`, so this is a thread-pool-exhaustion risk, not a lock-freeze risk.
|
||
|
||
**Root cause:** The per-node reads are serialized and unbounded per request; there is no per-request deadline and no server-side cap on concurrent HistoryRead work.
|
||
|
||
**Proposed design — bounded per-node parallelism + per-request deadline + concurrency limiter.**
|
||
1. **Parallelize per-node reads within a batch, bounded.** Replace the sequential `foreach` with a bounded-concurrency fan-out (`Parallel.ForEachAsync` with `MaxDegreeOfParallelism`, or `Task.WhenAll` over a `SemaphoreSlim`), then block-bridge **once** on the aggregate at the arm boundary. This turns N × 30s into ~ceil(N/P) × 30s worst case. Keep the per-node try/catch so one node's failure still surfaces `Bad` for that node only.
|
||
2. **Per-request deadline token.** Derive a `CancellationTokenSource` from a configurable server-side HistoryRead deadline (e.g. `ServerHistorian:HistoryReadDeadline`, default ~60s) and pass it into the gateway calls instead of `CancellationToken.None`, so a single request can't hold a thread indefinitely regardless of per-node `CallTimeout`.
|
||
3. **Server-side concurrent-HistoryRead limiter.** A process-wide `SemaphoreSlim` (configurable max, e.g. 8-16) gating entry to the arms so a flood of history clients can't consume more than a bounded slice of the request pool; excess requests wait (or fail with `BadTooManyOperations` if the queue is deep).
|
||
|
||
**Alternatives considered:** (a) Fully async HistoryRead — the SDK's `HistoryRead` override surface is synchronous (`void` returns filling `results`/`errors`), so the block-bridge is structural; parallelizing *within* the sync boundary is the achievable win. (b) Reject multi-node history requests — breaks legitimate clients. Bounded parallelism + deadline + limiter is the right combination.
|
||
|
||
**Implementation steps.**
|
||
1. `OtOpcUaNodeManager.cs` — refactor each arm's `foreach` into a bounded parallel fan-out. Because the arms fill `results[handle.Index]`/`errors[handle.Index]` by index (thread-safe, disjoint indices), parallel writes are safe. Collect the per-node async tasks, `await` them under one `GetAwaiter().GetResult()` at the arm boundary. Add a `MaxHistoryReadConcurrencyPerBatch` field (set from options, mirror `MaxTieClusterOverfetch` at `OtOpcUaServerHostedService.cs:223`).
|
||
2. Thread a per-request `CancellationToken` (from a deadline CTS) into `ServeNode`/`ServeRawPaged`/`HistoryReadEvents` in place of `CancellationToken.None`.
|
||
3. Add a process-wide `SemaphoreSlim` limiter (field on the node manager or a small injected service) around the arm bodies; make the max configurable.
|
||
4. `ServerHistorianOptions` — add `HistoryReadDeadline` (TimeSpan) + `MaxConcurrentHistoryReads` + `MaxHistoryReadConcurrencyPerBatch`. Wire through `OtOpcUaServerHostedService` (same spot as `MaxTieClusterOverfetch`).
|
||
|
||
**Tests.**
|
||
- **Unit:** with a stub `IHistorianDataSource` that delays each read, a batch of N nodes completes in ~ceil(N/P) × delay (proves parallelism); a per-node throw still yields `Bad` for that node and `Good` for others (regression on the existing per-node isolation); a batch exceeding the deadline cancels and returns `Bad`/`GoodNoData` rather than hanging.
|
||
- **Integration/live:** the env-gated live HistoryRead suite (needs the gateway) plus a stress case with a slow stub asserting the request pool isn't exhausted (measure that concurrent non-history services stay responsive while a slow history batch runs).
|
||
|
||
**Effort:** M. **Risk/blast-radius:** Medium — parallelizing gateway calls increases peak load on the historian gateway; the concurrency caps bound it. The index-disjoint result writes are safe, but verify no shared mutable state in `ServeRawPaged`'s paging/continuation-point synthesis is touched concurrently (it operates per-handle, but audit the continuation-point cache).
|
||
|
||
---
|
||
|
||
## S5 — MEDIUM — Redundancy NodeId identity string-matched across three sources, silent on mismatch
|
||
|
||
**Restatement:** `OpcUaPublishActor` matches `n.NodeId == _localNode.Value`; the snapshot derives `host:port` from the gossiped `Member.Address` while the local side derives it from configured `PublicHostname:Port`. A divergence (DNS vs IP, container advertised name) silently computes ServiceLevel 0 / keeps a stale role — the historical "silently inert delivery" bug shape, with no distinguishing log.
|
||
|
||
**Verification — Confirmed.**
|
||
- `OpcUaPublishActor.cs:512` — `var entry = _lastSnapshot.Nodes.FirstOrDefault(n => n.NodeId == _localNode.Value);`
|
||
- `:516-520` — `if (entry is null || entry.Role == Detached) { ServiceLevelChanged(0); return; }` — a **missing entry** is treated identically to legitimate Detached, silently → 0. No log distinguishes the two.
|
||
- `RedundancyStateActor.cs:123-134` — snapshot `NodeId` = `ToNodeId(member.Address)` from the **gossiped** `Member.Address`.
|
||
- `:141-146` — `ToNodeId` builds canonical `host:port` "the SAME format `ClusterRoleInfo.LocalNode` uses" — but the local side's host comes from configured `PublicHostname`, the snapshot side's from the gossip. They agree only if `PublicHostname` == the gossiped address host.
|
||
|
||
**Root cause:** Two independently-derived string identities are compared for equality with no assertion or diagnostic that they actually match. This is the same class as the historical redundancy-state-delivery bug (`project_redundancy_state_delivery.md`).
|
||
|
||
**Proposed design — log-once on absence + startup self-check.** This is diagnostic, not behavioral (the S4 fix consumes the same signal).
|
||
1. In `OpcUaPublishActor.RecomputeServiceLevel` (and the `DriverHostActor.OnRedundancyStateChanged`, `ScriptedAlarmHostActor` equivalents), when the received snapshot has ≥1 node but **none** equals `_localNode`, log **once** at `Warning`: `local node {LocalNodeId} absent from redundancy snapshot [{snapshotNodeIds}] — check PublicHostname vs gossiped Address`. Log-once (a bool latch) so it doesn't spam every heartbeat.
|
||
2. **Startup self-check:** in the cluster bootstrap or `OpcUaPublishActor.PreStart`, compare the locally-derived `ClusterRoleInfo.LocalNode` against `Cluster.Get(system).SelfMember.Address` projected through `ToNodeId`; if they differ, log `Error` (misconfiguration) — fail-fast-ish, this is a deploy config error.
|
||
3. Reset the log-once latch when a snapshot *does* contain the local node (so a transient bootstrap gap that self-heals doesn't leave a stale warning impression).
|
||
|
||
**Implementation steps.** Touch `OpcUaPublishActor.cs` (RecomputeServiceLevel + PreStart self-check), `DriverHostActor.OnRedundancyStateChanged` (`:1109`), and factor `ToNodeId(SelfMember.Address)` comparison into a shared helper in `ClusterRoleInfo` so the "canonical local node id" derivation is single-sourced (removes the third independent derivation).
|
||
|
||
**Tests.** Unit: snapshot with nodes but not the local node → the log-once fires exactly once and ServiceLevel is 0 (distinguishable in logs from Detached); a subsequent snapshot including the local node resets the latch. Startup self-check: mismatched `PublicHostname` vs self-address → Error logged.
|
||
|
||
**Effort:** S. **Risk/blast-radius:** Low (diagnostic + one shared helper).
|
||
|
||
---
|
||
|
||
## S6 — MEDIUM — Crashed-and-restarted `DriverInstanceActor` loses desired-subscription state; persistent thrower hot-loops
|
||
|
||
**Restatement:** No Runtime actor overrides `SupervisorStrategy`, so a driver-child exception triggers Akka's default one-for-one **unlimited, non-backoff** restart. Restart re-runs `PreStart` but the desired-subscription set arrives post-spawn via `SetDesiredSubscriptions` stored in actor state; restart wipes it and `DriverHostActor` has no restart-detection to re-send (restarts don't fire `Terminated`). A persistent thrower hot-loops with no backoff.
|
||
|
||
**Verification — Confirmed.**
|
||
- Grep for `SupervisorStrategy|BackoffSupervisor` in `src/Server/…/Runtime/` → **zero** hits (confirmed no override → default `OneForOneStrategy` with unlimited restarts, `Directive.Restart`, no backoff).
|
||
- `DriverInstanceActor.cs:84` — `SetDesiredSubscriptions` is a message record; `:181-194` — the desired set is stored in actor fields (`_desiredSubscriptions`); `:299 PreStart` re-inits the driver from Props but does **not** request the subscription set; `:861 StoreDesiredSubscriptions` stores it. A restart clears the fields and nothing re-sends.
|
||
- No `PostRestart` override that re-requests subscriptions.
|
||
|
||
**Root cause:** Message-delivered actor state (desired subscriptions) is not reconstructible from `Props` after a restart, and the parent has no restart-detection to re-push it; plus unlimited non-backoff restart on a persistent fault.
|
||
|
||
**Proposed design — BackoffSupervisor + child pulls state in PreStart.**
|
||
1. Wrap each `DriverInstanceActor` in a **`BackoffSupervisor`** (`Akka.Pattern.BackoffSupervisor` / `Backoff.OnFailure`) with exponential backoff (e.g. min 1s, max 30s, jitter) and a `SupervisorStrategy` on the child that restarts on transient faults. This kills the hot-loop.
|
||
2. Have the child **request its desired-subscription set from the parent in `PreStart`/`PostRestart`** — add a `RequestDesiredSubscriptions(driverInstanceId)` message the child sends to `DriverHostActor` on (re)start; the parent replies with the current desired set (which it already knows — it pushed it originally). This makes the subscription state reconstructible after any restart. Alternatively, have the parent detect the backoff-restart and re-push; the child-pull is cleaner (self-healing, no parent restart-detection needed).
|
||
|
||
**Alternatives considered:** (a) Move desired subscriptions into `Props` — they change per-deploy independently of the actor spec, so this couples deploy churn to actor respawn; rejected. (b) Akka.Persistence for the child — overkill; the parent is the source of truth and can re-supply. (c) Parent watches for restart via a custom decider — restarts don't fire `Terminated`, so this needs a bespoke signal; child-pull avoids it.
|
||
|
||
**Implementation steps.**
|
||
1. `DriverHostActor.cs` — spawn each driver child under a `BackoffSupervisor` Props (or apply `Backoff.OnFailure`) with exp backoff; keep the `(driver, FullName) ⇄ NodeId` maps keyed the same. Handle a new `RequestDesiredSubscriptions` message by replying the current desired set for that driver id.
|
||
2. `DriverInstanceActor.cs` — in `PreStart` (covers first start + restart, since Akka calls `PreStart` after `PostRestart`), send `RequestDesiredSubscriptions` to the parent and adopt the reply via the existing `StoreDesiredSubscriptions` path. Re-apply on the `Connected` entry as it already does (`:187`).
|
||
3. Ensure the backoff wrapper doesn't break the health-publish + routing (the parent addresses the *supervisor* ref; verify `_children` maps hold the right ref for `Ask`/`Tell` — with BackoffSupervisor you Tell the supervisor which forwards).
|
||
|
||
**Tests.**
|
||
- **Unit (TestKit) — the U6 gap:** force a driver child to throw; assert (a) it restarts with backoff (not immediately/unbounded), (b) after restart it re-requests and re-adopts the desired subscriptions, (c) a persistent thrower backs off (increasing delay) rather than hot-looping. This is the "no actor supervision/restart test anywhere" gap.
|
||
- **Live:** on the docker rig, kill a driver's backing connection to induce faults and confirm the driver recovers subscriptions after a transient throw without redeploy.
|
||
|
||
**Effort:** M. **Risk/blast-radius:** Medium — changes the driver-child supervision topology; verify routing/`Ask` still resolves through the supervisor and that the backoff doesn't delay legitimate fast recovery (tune min backoff low).
|
||
|
||
---
|
||
|
||
## S7 — MEDIUM — `DriverInstanceActor.PostStop` blocks shutdown on driver shutdown
|
||
|
||
**Restatement:** `_driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult()` in `PostStop` is the only synchronous block in the Runtime actors; a hung protocol stack stalls stop/re-deploy of the whole child set.
|
||
|
||
**Verification — Confirmed.** `DriverInstanceActor.cs:950` — `try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); } catch (…) { log }`. `CancellationToken.None` → unbounded.
|
||
|
||
**Root cause:** Unbounded synchronous wait on driver shutdown inside the actor stop path.
|
||
|
||
**Proposed design — bound the wait, log-and-abandon on expiry.** Wrap with a timeout: `_driver.ShutdownAsync(cts.Token).WaitAsync(TimeSpan.FromSeconds(N))` where the CTS is created with the same timeout, so both the token and the outer `WaitAsync` cap it. On `TimeoutException`, log `Warning` and abandon (the actor stops regardless; the orphaned shutdown task runs to completion in the background). A configurable `DriverShutdownTimeout` (default ~5s, matching the write ladder's 5s backend bound) is reasonable.
|
||
|
||
**Implementation steps.** `DriverInstanceActor.cs:950` — replace with a bounded wait; pass a real cancellation token (from a timeout CTS) into `ShutdownAsync` so cooperative drivers cancel promptly, and `WaitAsync(timeout)` as the hard bound for uncooperative ones. Optionally surface the timeout in options.
|
||
|
||
**Tests.** Unit: a stub driver whose `ShutdownAsync` never completes → `PostStop` returns within ~timeout and logs; a fast driver still shuts down cleanly. This complements S6's supervision tests.
|
||
|
||
**Effort:** S. **Risk/blast-radius:** Low.
|
||
|
||
---
|
||
|
||
## S8 — MEDIUM — Inbound Part 9 commands / native acks are at-most-once; client sees Good even if lost
|
||
|
||
**Restatement:** The alarm-command router (`Publish`) and native-ack router (`Tell`) are fire-and-forget, catch-log-drop; the node manager already returned `Good` and applied local condition state. A mediator hiccup or missing `DriverHostActor` silently strands upstream state. Deliberate (non-blocking under `Lock`) but invisible.
|
||
|
||
**Verification — Confirmed.**
|
||
- `OtOpcUaServerHostedService.cs:139-155` (alarm-command router) and `:167-194` (native-ack router) — both `try { … Tell/Publish … } catch (Exception ex) { _logger.LogWarning(…) }`, no counter/metric. The native-ack `else` branch (no `DriverHostActor`) also only `LogWarning`s (`:181`).
|
||
- `OtOpcUaNodeManager.cs:769-772` (referenced) — the Part 9 condition method returns `Good` before/independent of routing.
|
||
|
||
**Root cause:** Best-effort routing with only a Warning log — no operator-visible signal (metric/counter) and no reconciliation.
|
||
|
||
**Proposed design — add drop counters/metrics + optional reconciliation.**
|
||
1. Add OpenTelemetry counters (the codebase uses `OtOpcUaTelemetry` — e.g. `DriverInstanceLifecycle` at `DriverInstanceActor.cs:952`) for `alarm_command_route_dropped` and `native_ack_route_dropped`, tagged by operation/reason (`mediator_fault`, `no_driver_host`). This makes the silent drop observable in metrics/alerting.
|
||
2. (Optional, larger) an engine-side reconciliation sweep: periodically the scripted-alarm engine / driver re-asserts authoritative condition state so a dropped command self-heals on the next sweep. Defer unless operationally needed; the metric is the priority.
|
||
|
||
**Implementation steps.** `OtOpcUaServerHostedService.cs` — increment the counters in both catch blocks and the native-ack `else`. Add the counter definitions to `OtOpcUaTelemetry`. Surface in the existing metrics dashboard/health if present.
|
||
|
||
**Tests.** Unit: inject a throwing mediator accessor / a registry that returns no `DriverHostActor` → the counter increments and no exception escapes into the SDK path. (These routers are wired in `StartAsync`; testable by extracting the router lambdas or via a small harness.)
|
||
|
||
**Effort:** S. **Risk/blast-radius:** Low (additive observability).
|
||
|
||
---
|
||
|
||
## S9 — MEDIUM — Server certificate has no renewal or expiry monitoring
|
||
|
||
**Restatement:** The app cert is auto-created self-signed with SDK defaults (2048-bit, 12-month lifetime), checked only at boot. Nothing monitors expiry; ~12 months after first deploy, Sign/SignAndEncrypt endpoints and UserName-token encryption fail with no warning.
|
||
|
||
**Verification — Confirmed.** `OpcUaApplicationHost.cs:305-317` — `EnsureApplicationCertificateAsync` calls `CheckApplicationInstanceCertificatesAsync(false, null, ct)` with `minimumKeySize/lifetimeInMonths: 0` → SDK defaults (comment at `:308` states 2048-bit, 12-month). Boot-only; no periodic check, no expiry metric.
|
||
|
||
**Root cause:** No lifecycle monitoring for the server certificate (the AdminUI cert-actions cover *client* certs; the *server* cert is the gap).
|
||
|
||
**Proposed design — startup + periodic expiry check with a metric/health-check + runbook.**
|
||
1. At startup, after `EnsureApplicationCertificateAsync`, read the resolved cert's `NotAfter` and log `Warning` if within a threshold (e.g. 30 days) and `Info` with the expiry date otherwise.
|
||
2. Register a periodic check (a lightweight hosted service or a timer in the existing health pipeline) that emits a health-check/metric (`server_cert_days_to_expiry`) consumed by `MapOtOpcUaHealth` (same surface S10 targets). Degrade `/health` when under threshold.
|
||
3. Document a rotation runbook in `docs/security.md` (delete-and-reissue, or a longer `lifetimeInMonths` at issue). Optionally increase the issued lifetime (pass a non-zero `lifetimeInMonths` to `CheckApplicationInstanceCertificatesAsync`) so the reissue cadence is multi-year.
|
||
|
||
**Implementation steps.** `OpcUaApplicationHost.cs` — capture the cert `NotAfter` after the check and expose it (property or event); add the threshold log. Add a health contributor + metric. `docs/security.md` — runbook section.
|
||
|
||
**Tests.** Unit: a near-expiry cert → the health/metric reports degraded; a fresh cert → healthy. (Injectable via a test cert with a short `NotAfter`.)
|
||
|
||
**Effort:** S/M. **Risk/blast-radius:** Low.
|
||
|
||
---
|
||
|
||
## S10 — LOW — SDK start failure swallowed; node runs with Null sinks, no health surface
|
||
|
||
**Restatement:** `StartAsync` catches, logs, and returns; the node then no-ops all OPC UA work with nothing surfacing the condition through `/health` or ServiceLevel.
|
||
|
||
**Verification — Confirmed.** `OtOpcUaServerHostedService.cs:110-129` — `catch { LogError; return; }` (deliberate: AdminUI stays up). The `NodeManager is null` path (`:124-128`) also just warns and returns. No health flag set.
|
||
|
||
**Root cause:** The availability trade-off (don't crash the whole binary) leaves no degraded-state signal.
|
||
|
||
**Proposed design — set a health flag consumed by `MapOtOpcUaHealth`.** Introduce an `IOpcUaServerHealth` singleton (a simple thread-safe flag holder) that `OtOpcUaServerHostedService` sets to `Faulted`/`Degraded` on the start-failure and `NodeManager is null` paths, and `Healthy` on success. `MapOtOpcUaHealth` reads it so fleet status shows the degraded node. Pairs naturally with S9's cert health contributor.
|
||
|
||
**Implementation steps.** New `IOpcUaServerHealth` + impl in `Host`/`OpcUaServer`; inject into `OtOpcUaServerHostedService`; set on each exit path in `StartAsync` and clear on success/`StopAsync`; wire into the health-check mapping.
|
||
|
||
**Tests.** Unit: forcing a start failure sets the health flag to degraded; success sets healthy. Health endpoint reflects it.
|
||
|
||
**Effort:** S. **Risk/blast-radius:** Low.
|
||
|
||
---
|
||
|
||
## S11 — LOW — `AuditWriterActor` unbounded buffer, drops batches on DB outage (moot — no producers)
|
||
|
||
**Restatement:** The audit buffer is unbounded between flushes and drops whole batches on a DB outage. Best-effort by contract; currently moot because the pipeline has no producers (U3).
|
||
|
||
**Verification — Confirmed** (`ControlPlane/Audit/AuditWriterActor.cs` per report; and U3 confirms zero producers — `AuditOutcomeMapper.cs:12-18` states "no live structured AuditEvent emit sites").
|
||
|
||
**Root cause:** Best-effort design; no backpressure; and the whole pipeline is dormant.
|
||
|
||
**Proposed design.** **Fold into U3's decision.** If the audit pipeline is wired to producers (U3), then bound the buffer (drop-oldest with a metered counter, like `SqliteStoreAndForwardSink`'s `_evictedCount`) and consider a store-and-forward durable queue for compliance-grade audit. If the pipeline is deleted (U3 alternative), this is moot. **Do not fix in isolation** — resolve U3 first.
|
||
|
||
**Effort:** S (bounding) — gated on U3. **Risk:** Low.
|
||
|
||
---
|
||
|
||
## P2 — MEDIUM — Per-value global-lock write, one actor message per value, no batching
|
||
|
||
**Restatement:** Each published value takes the global node-manager `Lock` once and flows through one actor message per value, contending with SDK read/subscription/publish threads. At high tag counts × fast polls this serializes everything through one lock and allocates one record per hop.
|
||
|
||
**Verification — Confirmed.**
|
||
- `DriverHostActor.cs:561-589` (`ForwardToMux`) — per value: dictionary lookup + up to two `Tell`s.
|
||
- `OtOpcUaNodeManager.cs:266-281` (`WriteValue`) — takes `lock (Lock)` per value.
|
||
- `DependencyMuxActor.cs:95-108` — lean (early-drop, set fan-out), confirmed not the bottleneck.
|
||
|
||
**Root cause:** No batching of the sink write; the lock is acquired per value rather than per driver-publish cycle.
|
||
|
||
**Proposed design — batched `WriteValues(IReadOnlyList<…>)` sink call.** Add a batch method to `IOpcUaAddressSpaceSink` that takes one `Lock` hold per driver publish cycle and applies all values in the batch. The driver child already receives values in poll-cycle batches upstream; carry the batch shape through `ForwardToMux` → `OpcUaPublishActor` → `sink.WriteValues`. **Respect the U2 forwarding trap:** the new `WriteValues` must be added to `DeferredAddressSpaceSink`, `NullAddressSpaceSink`, and `SdkAddressSpaceSink`, and exhaustively test-guarded — an un-forwarded batch method ships inert (F10b/PR#423 precedent).
|
||
|
||
**Alternatives considered:** (a) Finer-grained locking in the node manager — the SDK's `CustomNodeManager2.Lock` is the contract boundary for address-space consistency; sub-locking risks correctness. Batching under the existing lock is safer. (b) Lock-free value store — too invasive. Batching is the contained seam-level win the report recommends.
|
||
|
||
**Implementation steps.** Add `WriteValues` to the sink interface + all impls + the deferred wrapper; thread batch shape through `ForwardToMux`/`OpcUaPublishActor`; keep single `WriteValue` for the individual paths (alarm/vtag) or route them through the batch of one.
|
||
|
||
**Tests.** Unit: batched write takes the lock once for N values (assert via a recording sink counting lock acquisitions or write calls); U2 forwarding test must include `WriteValues`. Perf: micro-benchmark N values single vs batched. Live: high-tag-count rig to confirm reduced contention (optional).
|
||
|
||
**Effort:** M. **Risk/blast-radius:** Medium (hot path; U2 trap). Do after/with P1 since both touch the sink seam.
|
||
|
||
---
|
||
|
||
## P3 — MEDIUM — HistoryRead-Events unbounded, no paging
|
||
|
||
**Restatement:** `EventMaxEvents` maps `NumValuesPerNode == 0` to `int.MaxValue`, and the Events arm never issues continuation points. A wide window over a busy alarm source materializes the entire result in memory on both gateway and server.
|
||
|
||
**Verification — Confirmed.** `OtOpcUaNodeManager.cs:1944-1954` (`EventMaxEvents` → `int.MaxValue` on 0), `:1919-1921` ("never issue continuation points — full window in one shot").
|
||
|
||
**Root cause:** Spec-conformant "no limit" translated to truly unbounded with no server-side backstop.
|
||
|
||
**Proposed design — server-side max mirroring `MaxTieClusterOverfetch`.** Impose a configurable server-side max event count for the Events arm (e.g. `ServerHistorian:MaxHistoryReadEvents`, default 65536 to mirror `MaxTieClusterOverfetch`'s philosophy). When the result would exceed it, either **fail loudly** (`BadHistoryOperationInvalid` / a status telling the client to narrow the window) or **page** (synthesize continuation points as the Raw arm already does — larger effort). Start with the bounded-fail backstop (matches the Raw tie-cluster "loud-fail" design at `:2202`), add paging later if clients need it.
|
||
|
||
**Implementation steps.** `OtOpcUaNodeManager.cs` — cap `EventMaxEvents`'s unbounded case at the configured max; on overflow return a loud status. Add the option to `ServerHistorianOptions`, wire via `OtOpcUaServerHostedService` (same spot as `MaxTieClusterOverfetch`).
|
||
|
||
**Tests.** Unit: `EventMaxEvents(0)` returns the configured cap (not `int.MaxValue`); a result at the cap returns the loud status. Update the existing `EventMaxEvents` unit tests.
|
||
|
||
**Effort:** S. **Risk/blast-radius:** Low.
|
||
|
||
---
|
||
|
||
## P4 — LOW — Every deploy re-runs the four Materialise passes over the full composition
|
||
|
||
**Verification — Confirmed.** `OpcUaPublishActor.cs:338-354` runs all four passes each deploy; acceptable because `EnsureFolder`/`EnsureVariable` early-return on existing ids, but each pass takes/releases `Lock` per node.
|
||
|
||
**Proposed design.** **Fold into P2's batching** — a batched ensure (one lock hold per pass) removes the per-node lock churn. No standalone work; note it when implementing P2. The positive designs (Raw tie-cluster overfetch bound, `HandleDiscoveredNodes` unchanged-plan short-circuit at `DriverHostActor.cs:658-673`) are worth preserving.
|
||
|
||
**Effort:** S (subsumed by P2). **Risk:** Low.
|
||
|
||
---
|
||
|
||
## C1 — MEDIUM — `ServerHistorian` bound imperatively in five places, no `IOptions`
|
||
|
||
**Restatement:** `ServerHistorian` is bound in `Program.cs` (own bind + `Validate()`), `AddServerHistorian`, `AddAlarmHistorian`, `AddHistorianProvisioning`, and `OtOpcUaServerHostedService`'s ctor — five `Get<>` sites that can log warnings twice and drift.
|
||
|
||
**Verification — Confirmed.** `OtOpcUaServerHostedService.cs:88-90` binds directly (`configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>()`); `Runtime/ServiceCollectionExtensions.cs:86, 132, 168` each re-`Get<>` the section (confirmed the section is read in `AddServerHistorian`/`AddAlarmHistorian`/`AddHistorianProvisioning`); `Program.cs` binds + `Validate()`s. Five sites.
|
||
|
||
**Root cause:** No single validated-options registration; each consumer binds independently.
|
||
|
||
**Proposed design — one `AddValidatedOptions<ServerHistorianOptions>` + inject `IOptions<>`.** Mirror the `OpcUa`/`Ldap` pattern already in the tree (`Program.cs:102, 254-255`). Register once with `ValidateOnStart` (folds into C2's validator promotion), then inject `IOptions<ServerHistorianOptions>` into `OtOpcUaServerHostedService` and the three `Add*` extensions. This single-sources the section and runs `Validate()` once.
|
||
|
||
**Implementation steps.** Add the registration in `Program.cs`; change the five consumers to take `IOptions<ServerHistorianOptions>` (the `Add*` extensions resolve it from the built provider or accept it as a parameter). Remove the per-site `Get<>` calls.
|
||
|
||
**Tests.** Unit: options bound once, validated once (assert single warning emission). Existing consumers still function.
|
||
|
||
**Effort:** S/M. **Risk/blast-radius:** Low-Medium (touches five wiring sites; verify the `Add*` extensions run after the options registration).
|
||
|
||
---
|
||
|
||
## C2 — MEDIUM — Two-tier options validation; `DevStubMode` only log-warned in production
|
||
|
||
**Restatement:** `OpcUa`/`Security:Ldap` get fail-fast `ValidateOnStart` validators; `ServerHistorian`/`ContinuousHistorization`/`AlarmHistorian` get only advisory `Validate()` warnings; `DevStubMode=true` (accept-any-credentials Administrator) is merely log-warned in production.
|
||
|
||
**Verification — Confirmed.** `OtOpcUaLdapAuthService.cs:79-88` — `if (_options.DevStubMode) { … LogWarning("DevStubMode bypass …"); accept }` with no environment guard (grep for `IsDevelopment`/`EnvironmentName` in the file → none). The historian sections use advisory `Validate()` (C1).
|
||
|
||
**Root cause:** Inconsistent validation posture; a dangerous dev bypass isn't environment-gated.
|
||
|
||
**Proposed design.**
|
||
1. **Promote the historian sections to the validator pattern** (`AddValidatedOptions` + `IValidateOptions<>` with `ValidateOnStart`), folding in C1's single registration. Convert the current `Validate()` warning lists into validator failures (or keep soft warnings for non-fatal knobs but fail on genuinely-invalid combos, e.g. `Enabled=true` with empty `Endpoint`).
|
||
2. **Environment-gate `DevStubMode`:** inject `IHostEnvironment`; if `DevStubMode == true && !env.IsDevelopment()`, **fail startup** (throw in a validator) — or at minimum refuse the bypass at runtime (deny + Error log) outside Development. Fail-fast is preferable (a prod deploy with DevStubMode is a critical misconfiguration).
|
||
|
||
**Implementation steps.** Add `ServerHistorianOptionsValidator`/`ContinuousHistorizationOptionsValidator`/`AlarmHistorianOptionsValidator` (mirror `OpcUaApplicationHostOptionsValidator`/`LdapOptionsValidator`). Add an `LdapOptionsValidator` (or extend the existing one) rule: `DevStubMode` requires Development. Wire `IHostEnvironment` into the validator.
|
||
|
||
**Tests.** Unit: historian validators fail on invalid combos, `ValidateOnStart` surfaces at boot; `DevStubMode=true` outside Development fails startup; inside Development it's allowed (warned). Existing `OpcUa`/`Ldap` validator tests stay green.
|
||
|
||
**Effort:** M. **Risk/blast-radius:** Medium — promoting to fail-fast could break a deploy relying on a currently-tolerated invalid config; validate against real appsettings before merge and document the new hard requirements in the migration note.
|
||
|
||
---
|
||
|
||
## C3 — LOW — Library code logs via static Serilog; node manager uses obsolete `Utils.LogError`
|
||
|
||
**Verification — Confirmed.** `Runtime/ServiceCollectionExtensions.cs:90,100,136` log via static `Log`; node manager uses `Utils.LogError` with `#pragma warning disable CS0618` (e.g. `:1928-1930`, and the report cites `:449-451` et al.).
|
||
|
||
**Proposed design.** For the Runtime static-logger sites, inject `ILogger` where an instance is available; where it isn't (pure `Add*` extension methods), accept an `ILoggerFactory`/`ILogger` parameter. For the node manager, wire the acknowledged `ITelemetryContext`/`ILogger` seam so the six `CS0618` `Utils.LogError` sites route through a real logger instead of the obsolete static trace. This is hygiene; the static coupling works only because `Program.cs:305` assigns `Log.Logger` (ordering-sensitive).
|
||
|
||
**Effort:** M (node-manager logger wiring is the bulk). **Risk:** Low. Lower priority than the Criticals/Highs.
|
||
|
||
---
|
||
|
||
## C4 — LOW — `IHistorianProvisioning` resolve has no missing-registration warning
|
||
|
||
**Verification — Confirmed.** `Runtime/ServiceCollectionExtensions.cs:298` — `var provisioning = resolver.GetService<IHistorianProvisioning>();` with a comment noting the Null default, but **no warning** parallel to the evaluator/recorder-deps warnings (`:211-237`). This is the exact seam that shipped dormant in PR #423. The `dispatched=N, requested=0` tally (`AddressSpaceApplier.cs:274-282`) partially compensates.
|
||
|
||
**Proposed design.** Add a startup log line at the resolve site: if `GetService<IHistorianProvisioning>()` returns the `NullHistorianProvisioning` singleton while `ServerHistorian:Enabled == true`, log `Warning` ("provisioning enabled but Null provisioner resolved"). Mirror the sibling missing-registration warnings. This is the "startup log proving the wiring" guard from OVERALL theme #1.
|
||
|
||
**Effort:** S. **Risk:** Low.
|
||
|
||
---
|
||
|
||
## U1 — HIGH (doc drift) — CLAUDE.md "Known Limitation 2" is stale; recorder ref-feed is wired
|
||
|
||
**Restatement:** CLAUDE.md says continuous historization "records no values" because the recorder is seeded empty. In code, the applier feeds the delta via `ActorHistorizedTagSubscriptionSink` → `UpdateHistorizedRefs`.
|
||
|
||
**Verification — Confirmed stale.**
|
||
- `Runtime/ServiceCollectionExtensions.cs:272` — recorder still spawned with `historizedRefs: Array.Empty<string>()`.
|
||
- `:291` — `new ActorHistorizedTagSubscriptionSink(continuousRecorder)` is wired.
|
||
- `AddressSpaceApplier.cs:213` (`FeedHistorizedRefs`) + `:355-371` (`HistorizedRef`) feed the add/remove delta.
|
||
- Restart convergence works via in-memory `_lastApplied` (`OpcUaPublishActor.cs:326-336`): the first post-boot rebuild diffs against empty and emits the full historized set as Added.
|
||
- Confirmed independently by OVERALL cross-cutting theme #5.
|
||
|
||
**Proposed design — update the doc + add the convergence test.**
|
||
1. Update `CLAUDE.md` — rewrite "KNOWN LIMITATION 2" to state the ref-feed is wired; note the load-bearing chain (empty seed + delta feed + in-memory `_lastApplied` → full-set-as-Added on first post-boot deploy). Keep any genuinely-remaining caveat (e.g. the numeric-analog-only v1 limitation) but drop the "records no values" claim. Propagate to `../scadaproj/CLAUDE.md`'s OtOpcUa entry per the repo's cross-repo rule.
|
||
2. Add an **explicit restart-convergence test:** boot → apply a full plan (composition with historized tags) → assert the recorder's dependency-mux interest is registered for exactly the historized set (the chain is only implicitly covered today). This retires the "load-bearing but untested" risk.
|
||
|
||
**Effort:** S. **Risk:** Low (doc + test only). High-value because it removes a false "known broken" claim that could misdirect future work.
|
||
|
||
---
|
||
|
||
## U2 — MEDIUM — Deferred-sink forwarding correct today but only half test-guarded
|
||
|
||
**Restatement:** `DeferredAddressSpaceSink` forwards all 10 members correctly (incl. both surgical methods with capability-sniffing), but tests assert forwarding for only ~5/10 members. The F10b incident proves the failure mode is real.
|
||
|
||
**Verification — Confirmed.** `DeferredAddressSpaceSink.cs:15` implements `IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink`; all members forward (`:26-49` for the 8 base members, `:58-69` for the 2 surgical with `_inner is ISurgicalAddressSpaceSink` fallback). Correct today, but per the report only ~5 have per-member forwarding assertions.
|
||
|
||
**Proposed design — reflection-driven exhaustive forwarding test.** Add a test that, via reflection, enumerates **every method** of `IOpcUaAddressSpaceSink` + every optional capability interface the wrapper implements (`ISurgicalAddressSpaceSink`, plus any new ones from P1/P2), invokes each on a `DeferredAddressSpaceSink` wrapping a recording inner sink, and asserts the call **reached** the inner sink. This mechanically guarantees no member ships inert — retiring the whole trap class (OVERALL theme #1/#2). Apply the same treatment to `DeferredServiceLevelPublisher`. **This test becomes the gate for P1's remove methods and P2's `WriteValues`.**
|
||
|
||
**Implementation steps.** New test in `tests/Server/…/DeferredAddressSpaceSinkTests.cs` (extend the existing file): reflect over interface methods, build default/dummy args per parameter type, invoke, assert the recording sink saw each. Handle the capability-sniffing members (assert they forward when the inner implements the capability, and no-op/return-false safely when it doesn't).
|
||
|
||
**Effort:** S/M. **Risk:** Low. Very high leverage — one test permanently closes the "built-but-never-wired forwarding" class.
|
||
|
||
---
|
||
|
||
## U3 — MEDIUM — Structured audit pipeline built/tested with zero producers
|
||
|
||
**Verification — Confirmed.** `ControlPlane/Audit/AuditOutcomeMapper.cs:12-18` and `Security/Audit/AuditActor.cs:18-24` state there are no live structured `AuditEvent` emit sites (all production audit flows through the bespoke stored-procedure path).
|
||
|
||
**Proposed design — decide: wire or delete.** Either (a) wire producers (route the real audit-worthy events — the write-gate audit at `OtOpcUaNodeManager.cs`, alarm ack/shelve, deploy outcomes — through the structured `AuditEvent` pipeline, replacing/augmenting the stored-proc path), or (b) delete the dormant pipeline (`AuditWriterActor`, `AuditOutcomeMapper`, `AuditActor`, tests) to remove decay risk. **Recommendation:** delete unless compliance-grade structured audit is a near-term requirement — the bespoke stored-proc path already serves production, and dormant tested code is the exact "built-but-never-wired" debt the review flags. S11 is subsumed by whichever path is chosen. This is a product decision; surface it to the owner rather than deciding unilaterally.
|
||
|
||
**Effort:** M (either path). **Risk:** Low (deletion) / Medium (wiring — new producers touch hot paths).
|
||
|
||
---
|
||
|
||
## U4 — MEDIUM — `FleetStatusBroadcaster.DriverHostStatusHeartbeat` dead code with latent NodeId-mismatch
|
||
|
||
**Verification — Confirmed** (per report: `FleetStatusBroadcaster.cs:38, 110-121` no producer; `:152-159` keys nodes by **host only** vs the `host:port` canon everywhere else).
|
||
|
||
**Proposed design — wire the producer or remove; canonicalize the key regardless.** If fleet-status freshness is wanted, wire `DriverHostActor` (which knows its applied revision) to send `DriverHostStatusHeartbeat` and **canonicalize the broadcaster key to `host:port`** (else a wired `host:port` NodeId creates phantom records — re-enacting the historical NodeId bug, same class as S5). If not wanted, delete the heartbeat path. Either way, fix the key to `host:port`. Recommendation: remove unless the fleet-status freshness signal is used by the AdminUI; the host-only key + no producer signals it was never completed.
|
||
|
||
**Effort:** S (remove) / M (wire + key fix). **Risk:** Low.
|
||
|
||
---
|
||
|
||
## U5 — MEDIUM — Native Part 9 conditions support Acknowledge-to-driver only
|
||
|
||
**Verification — Confirmed** (`OtOpcUaNodeManager.cs:647-658` — Confirm/AddComment/Shelve on a native condition route to the scripted engine which doesn't own them; `:698-703` — Enable/Disable → `BadNotSupported`). Operators shelving a Galaxy alarm via a Part 9 client get silent upstream no-op.
|
||
|
||
**Proposed design — route native condition commands to the driver's `IAlarmSource`, or fail loudly.** Extend the native-ack seam (already present: `NativeAlarmAckRouter` → `DriverHostActor.RouteNativeAlarmAck` → driver `AcknowledgeAsync`) to the other Part 9 operations the driver can support (Confirm/AddComment/Shelve/Unshelve) by adding driver-side methods on `IAlarmSource` (mirroring `AcknowledgeAsync`) and routing them like the ack. For operations no driver backend supports (Enable/Disable upstream), return a **loud** status (`BadNotSupported` is already returned for Enable/Disable — keep, but ensure the *silently-ineffective* Shelve/Confirm cases either route or return a status telling the operator it won't propagate upstream). This is documented H6c scope — scope it as a follow-on feature, not a quick fix.
|
||
|
||
**Effort:** M/L (driver-side `IAlarmSource` surface expansion + Galaxy gateway support). **Risk:** Medium (touches the driver alarm contract and Galaxy). Lower priority than the availability/perf cluster.
|
||
|
||
---
|
||
|
||
## U6 — MEDIUM — Test-coverage gaps concentrate on the fragile seams
|
||
|
||
**Restatement:** DPS delivery of redundancy/ServiceLevel state, actor supervision/restart, outbox durability across process restart, hard-kill failover, and the single `OpcUaServer.IntegrationTests` test are all untested; docker-gated protocol tests self-skip to green.
|
||
|
||
**Verification — Confirmed** (per report's tests/Server sweep; corroborated by S1/S6 verification — no supervision test, no hard-kill test; and OVERALL theme #4).
|
||
|
||
**Proposed design — targeted tests, delivered alongside the fixes above (not as a separate epic).**
|
||
- **DPS delivery of redundancy/ServiceLevel** — a real over-the-mediator delivery test (not a stub) on the 2-node harness; assert a `RedundancyStateChanged` published by `RedundancyStateActor` is *received* by `OpcUaPublishActor`/`DriverHostActor` and drives ServiceLevel/role. This is the known "unit tests can't catch it" blind spot (`project_redundancy_state_delivery.md`).
|
||
- **Hard-kill failover** — delivered by **S1**'s integration test.
|
||
- **Actor supervision/restart** — delivered by **S6**'s TestKit test.
|
||
- **Outbox durability across process restart** — a test that appends to the FasterLog outbox, simulates a restart (new outbox instance over the same directory), and asserts un-acked entries drain (continuous-historization path).
|
||
- **`OpcUaServer.IntegrationTests` expansion** — subscription-survival-on-add (from **P1**), a security-mode matrix (None/Sign/SignAndEncrypt over the wire), and a HistoryRead round-trip.
|
||
- **Make docker skips visible** — align with OVERALL action #6 (fail-on-skip for the integration job); coordinate with report 07.
|
||
|
||
**Effort:** M (spread across the fixes). **Risk:** Low (test-only). Track each sub-test with its parent finding so coverage lands with the fix, not after.
|
||
|
||
---
|
||
|
||
## U7 / U8 / U9 — LOW — Batched hygiene items
|
||
|
||
- **U7** — `IHistoryWriter` permanently Null-wired (`Runtime/ServiceCollectionExtensions.cs:56-58`, infra-gated on a nonexistent live-write RPC); **H2 HistoryUpdate** unimplemented (`OtOpcUaNodeManager.cs:1797-1801`, `IsReadModified` rejected); `LdapAuthFailure` lacks a `DirectoryUnavailable` value (directory-down overloaded onto `ServiceAccountBindFailed`). **Verification — Confirmed** (all backlog/tracked). **Design:** leave `IHistoryWriter`/H2 as tracked backlog (infra-gated). Add the `DirectoryUnavailable` enum value and map directory-down to it in the LDAP path (pairs with S2's outage handling — a distinct failure reason improves the operator signal). **Effort:** S (enum) + backlog (rest).
|
||
- **U8** — `BuildSecurityPolicies` doc claims the empty-profile fallback is "logged and very visible" but logs nothing (`OpcUaApplicationHost.cs:376-418`). Defused in prod by the `MinCount ≥ 1` validator but silent for direct embedders. **Verification — Confirmed** (per report). **Design:** either add the log the comment promises (a `Warning` on empty-profile fallback-to-None) or fix the comment. Adding the log is cheap and correct. **Effort:** S.
|
||
- **U9** — `EnsureVariable` silently ignores changed historize-intent on an existing node (`OtOpcUaNodeManager.cs:1345-1349`); correct today because the planner routes such deltas elsewhere, but the invariant lives in two comments. **Verification — Confirmed.** **Design:** turn the invariant into an assert/`Debug.Assert` (or a `Warning` log) when `EnsureVariable` is called with a differing historize-intent for an existing id, so a future planner regression surfaces instead of silently no-op'ing. **Effort:** S.
|
||
|
||
**Risk (all three):** Low.
|
||
|
||
---
|
||
|
||
## Suggested execution order (this report's slice of the OVERALL list)
|
||
|
||
1. **S1** — activate SBR + hard-kill failover test (OVERALL #1). *Do first — nothing else matters if failover is broken.*
|
||
2. **U1** — update CLAUDE.md (stale Known Limitation 2) + convergence test (OVERALL #11). *Cheap, removes a misleading claim before others build on it.*
|
||
3. **U2** — reflection-exhaustive Deferred-sink forwarding test (OVERALL theme #1/#2). *Prerequisite guard for P1/P2 — land it first so P1's remove methods and P2's `WriteValues` can't ship inert.*
|
||
4. **S4** — primary-gate default-deny on multi-node (OVERALL #7). *Small, closes the dual-primary data-plane window.*
|
||
5. **P1** — surgical pure-adds (phase 1), scoped removals (phase 2) (OVERALL #8). *Highest operational perf/stability win; gated on U2.*
|
||
6. **S2 / S3** — async LDAP timeout + channelized/bounded HistoryRead (OVERALL #9).
|
||
7. **S5, S6, S7, S8** — redundancy-mismatch diagnostics, BackoffSupervisor + subscription re-pull, bounded PostStop, drop metrics.
|
||
8. **C1 + C2** — single validated `ServerHistorian` options + historian validators + `DevStubMode` env-gate.
|
||
9. **P2 (+P4), P3, S9, S10** — batched sink writes, Events cap, cert monitoring, start-failure health flag.
|
||
10. **U3, U4, U5, C3, C4, U7-U9** — dormant-code decisions, native Part 9 expansion, and hygiene, as capacity allows.
|