refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
+23 -23
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.AuditLog` |
| Module | `src/ZB.MOM.WW.ScadaBridge.AuditLog` |
| Design doc | `docs/requirements/Component-AuditLog.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -66,7 +66,7 @@ chain doesn't reject a central composition root that mistakenly calls the site b
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs:45`, `src/ScadaLink.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs:86`, `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs:198` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs:45`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs:86`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs:198` |
**Description**
@@ -78,7 +78,7 @@ interface; `ClusterClientSiteAuditClient.IngestCachedTelemetryAsync` builds the
`IngestCachedTelemetryCommand`; the proto carries `CachedTelemetryBatch`;
`AuditLogIngestActor.OnCachedTelemetryAsync` performs the dual `InsertIfNotExists` +
`UpsertAsync` inside a `BeginTransactionAsync`. But a `grep` for callers of
`IngestCachedTelemetryAsync` in `src/ScadaLink.AuditLog` shows only the interface
`IngestCachedTelemetryAsync` in `src/ZB.MOM.WW.ScadaBridge.AuditLog` shows only the interface
declaration and the two implementations — nothing produces a `CachedTelemetryBatch` for
the site to push. The `SiteAuditTelemetryActor.OnDrainAsync` only calls
`IngestAuditEventsAsync` (the audit-only path); cached-call audit rows written by
@@ -108,17 +108,17 @@ previously-unreachable `IngestCachedTelemetryAsync` client path now carries
cached-call lifecycle rows from the site SQLite hot-path through to the central
`AuditLogIngestActor.OnCachedTelemetryAsync` dual-write transaction. Changes:
- **`ISiteAuditQueue`** (`src/ScadaLink.Commons/Interfaces/Services/ISiteAuditQueue.cs`):
- **`ISiteAuditQueue`** (`src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/ISiteAuditQueue.cs`):
added `ReadPendingCachedTelemetryAsync(int, CancellationToken)` returning
rows in `AuditForwardState.Pending` whose `Kind` is one of `CachedSubmit`,
`ApiCallCached`, `DbWriteCached`, `CachedResolve`. Updated `ReadPendingAsync`
XML doc to call out the partition.
- **`SqliteAuditWriter`** (`src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs`):
- **`SqliteAuditWriter`** (`src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs`):
implemented `ReadPendingCachedTelemetryAsync` with a `Kind IN (...)` filter
reusing the existing `_readConnection` / `_readLock` decoupling; modified
`ReadPendingAsync` to add the symmetric `Kind NOT IN (...)` predicate so the
audit-only drain no longer double-emits cached rows.
- **`SiteAuditTelemetryActor`** (`src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs`):
- **`SiteAuditTelemetryActor`** (`src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs`):
added an optional `IOperationTrackingStore?` constructor parameter, a sibling
`CachedDrain` self-tick message, and an `OnCachedDrainAsync` handler running
in parallel with the existing audit-only drain. The cached-drain reads the
@@ -130,12 +130,12 @@ cached-call lifecycle rows from the site SQLite hot-path through to the central
blocks the rest of the batch; rows stay Pending and reconciliation /
retention handles them. The lifecycle CTS (AuditLog-010) gates both drains
uniformly.
- **`AkkaHostedService`** (`src/ScadaLink.Host/Actors/AkkaHostedService.cs`):
- **`AkkaHostedService`** (`src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs`):
resolves `IOperationTrackingStore` via `GetService` (site-only registration)
and threads it through the actor's `Props.Create`. Central composition
roots and tests that don't register the tracking store get the legacy
audit-only behaviour — the cached scheduler is never armed.
- **Tests** (`tests/ScadaLink.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs`):
- **Tests** (`tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/Telemetry/SiteAuditTelemetryActorTests.cs`):
added three regression tests asserting (1) cached rows route through
`IngestCachedTelemetryAsync` and NOT `IngestAuditEventsAsync`, (2) an
orphan row with no tracking snapshot is logged + skipped without crashing
@@ -163,11 +163,11 @@ cached-call lifecycle rows from the site SQLite hot-path through to the central
is shared so `PostStop` cancels in-flight cached lookups + pushes at the
same instant as audit-only drains.
Build: `dotnet build ScadaLink.slnx` — 0 warnings, 0 errors.
Tests: `dotnet test tests/ScadaLink.AuditLog.Tests` — 250 passed, 1 failed
Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — 0 warnings, 0 errors.
Tests: `dotnet test tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests` — 250 passed, 1 failed
(`PartitionPurgeTests.EndToEnd_OldestPartition_PurgedViaActor_NewerKept`
pre-existing MS-SQL date-sensitive flake, called out in the prompt as
acceptable). `dotnet test tests/ScadaLink.SiteRuntime.Tests` — all 302
acceptable). `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests` — all 302
passed.
### AuditLog-002 — `SupervisorStrategy` comments claim Resume semantics but code returns the default Restart decider
@@ -177,7 +177,7 @@ passed.
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs:99-103`, `src/ScadaLink.AuditLog/Central/AuditLogPurgeActor.cs:109-115`, `src/ScadaLink.AuditLog/Central/SiteAuditReconciliationActor.cs:315-321` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs:99-103`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs:109-115`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationActor.cs:315-321` |
**Description**
@@ -224,7 +224,7 @@ override as a children-only forward-compat placeholder, and state the actual
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs:133`, `src/ScadaLink.AuditLog/Central/AuditLogPurgeActor.cs:139`, `src/ScadaLink.AuditLog/Central/SiteAuditReconciliationActor.cs:178` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs:133`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs:139`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationActor.cs:178` |
**Description**
@@ -267,7 +267,7 @@ dispose asynchronously across every audit ingest path.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Central/SiteAuditReconciliationActor.cs:233-265` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationActor.cs:233-265` |
**Description**
@@ -318,7 +318,7 @@ now tracks `_failedInsertAttempts: Dictionary<Guid, int>` and a per-tick
The in-memory counter resets on singleton restart, which is safe because the
cursor also resets and the next tick re-pulls everything. Tests for both the
retry-hold and permanent-abandon paths should land alongside the existing
reconciliation tests in `tests/ScadaLink.AuditLog.Tests/Central/` (deferred to
reconciliation tests in `tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/` (deferred to
the next coverage sweep — the logic is straightforward and the build/integration
tests already exercise the success path).
@@ -329,7 +329,7 @@ tests already exercise the success path).
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs:597-657` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs:597-657` |
**Description**
@@ -380,7 +380,7 @@ behind every batch INSERT under `_writeLock`).
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs:697-700` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs:697-700` |
**Description**
@@ -426,7 +426,7 @@ documents the choice. Behaviour for context-free callers is unchanged.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs:148-218` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs:148-218` |
**Description**
@@ -477,10 +477,10 @@ resolution instead.
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Site/FallbackAuditWriter.cs:51-77`, `src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs:77-104`, `src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs:125,155` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/FallbackAuditWriter.cs:51-77`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/CentralAuditWriter.cs:77-104`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs:125,155` |
**Resolution (2026-05-28):** New `SafeDefaultAuditPayloadFilter` in
`src/ScadaLink.AuditLog/Payload/` — a stateless singleton that performs HTTP
`src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/` — a stateless singleton that performs HTTP
header redaction for the hard-coded sensitive defaults (Authorization,
X-Api-Key, Cookie, Set-Cookie). The three writer-chain sites
(`FallbackAuditWriter`, `CentralAuditWriter`, `AuditLogIngestActor`
@@ -528,7 +528,7 @@ _Unresolved._
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs:706-740` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs:706-740` |
**Description**
@@ -572,7 +572,7 @@ the loop has drained (in the second lock block). Behaviour unchanged.
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs:92,107,124`, `src/ScadaLink.AuditLog/Central/SiteAuditReconciliationActor.cs:228` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs:92,107,124`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationActor.cs:228` |
**Description**
@@ -614,7 +614,7 @@ existing top-level catch swallows the `OperationCanceledException`.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs:53-55, 263-276, 301-346` |
| Location | `src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs:53-55, 263-276, 301-346` |
**Description**
+39 -39
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.CLI` |
| Module | `src/ZB.MOM.WW.ScadaBridge.CLI` |
| Design doc | `docs/requirements/Component-CLI.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -50,7 +50,7 @@ Critical/High issues; the module remains healthy.
#### Re-review 2026-05-28 (commit `1eb6e97`)
The CLI has grown two substantial new command groups since the last re-review —
`scadalink audit` (Audit Log #23 M8) and `scadalink bundle` (Transport #24) — together
`scadabridge audit` (Audit Log #23 M8) and `scadabridge bundle` (Transport #24) — together
adding ~1,500 lines of new production code. The new `audit` surface is well-tested and
well-factored (pure helpers + a clear `IAuditFormatter` seam), but the new `bundle`
surface is untested, duplicates the URL/credential resolution that already exists in
@@ -70,7 +70,7 @@ findings (none Critical, three Medium).
memory and writes synchronously — 100 MB bundles double-buffer.
- **CLI-020** — `BundleCommands.bundle export` parses the success body with bare
`JsonDocument.Parse` + `GetProperty` and throws on a malformed/abbreviated envelope.
- **CLI-021** — `CliConfig.Load` crashes the whole CLI when `~/.scadalink/config.json`
- **CLI-021** — `CliConfig.Load` crashes the whole CLI when `~/.scadabridge/config.json`
is malformed or unreadable, even if `--url` was supplied on the command line.
- **CLI-022** — `AuditCommands` and `BundleCommands` are absent from `CommandTreeTests`;
the test still pins `Equal(14, groups.Count)` and silently excludes the new groups.
@@ -133,7 +133,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:18`, `src/ScadaLink.CLI/Commands/DebugCommands.cs:45`, `src/ScadaLink.CLI/CliConfig.cs:37-39` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:18`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs:45`, `src/ZB.MOM.WW.ScadaBridge.CLI/CliConfig.cs:37-39` |
**Description**
@@ -144,8 +144,8 @@ with `var format = result.GetValue(formatOption) ?? "json";` and `formatOption`
in `Program.cs:11` with `DefaultValueFactory = _ => "json"`. `GetValue` therefore always
returns a non-null value ("json" when the flag is absent), so the `?? "json"` fallback never
fires and `config.DefaultFormat` is never consulted. The env var and config-file format
settings are dead code: `scadalink site list` always outputs JSON regardless of
`SCADALINK_FORMAT=table` or a `defaultFormat` entry in `~/.scadalink/config.json`. The
settings are dead code: `scadabridge site list` always outputs JSON regardless of
`SCADALINK_FORMAT=table` or a `defaultFormat` entry in `~/.scadabridge/config.json`. The
documented behaviour silently does not work.
**Recommendation**
@@ -171,7 +171,7 @@ now call `ResolveFormat`. Regression tests added in `FormatResolutionTests`.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:59-68`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:78-80` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:59-68`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:78-80` |
**Description**
@@ -205,7 +205,7 @@ case, prints `(ok)`, and returns 0 before any parse. Regression tests added in
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:80` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:80` |
**Description**
@@ -236,7 +236,7 @@ raw-body fallback on the JSON path. Regression test
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/ManagementHttpClient.cs:13` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/ManagementHttpClient.cs:13` |
**Description**
@@ -268,7 +268,7 @@ http/https URL via `Uri.TryCreate`. Both `CommandHelpers.ExecuteCommandAsync` an
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58`, `src/ScadaLink.CLI/Commands/InstanceCommands.cs:181-182` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs:55-58`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs:181-182` |
**Description**
@@ -305,7 +305,7 @@ wrong element types, and JSON null).
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Program.cs:9`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:36-44` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs:9`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:36-44` |
**Description**
@@ -345,7 +345,7 @@ demands it. Regression tests in `CredentialResolutionTests`.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `docs/requirements/Component-CLI.md:51-211` (vs. all files under `src/ScadaLink.CLI/Commands/`) |
| Location | `docs/requirements/Component-CLI.md:51-211` (vs. all files under `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/`) |
**Description**
@@ -372,21 +372,21 @@ A reader following the design doc would be unable to drive the CLI.
**Recommendation**
Regenerate the "Command Structure" section of `Component-CLI.md` from the actual command
tree (the in-repo `src/ScadaLink.CLI/README.md` is much closer to reality and could be the
tree (the in-repo `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` is much closer to reality and could be the
source), or mark the doc's command list as illustrative and point to the README as
authoritative.
**Resolution**
Resolved 2026-05-16 (commit pending). Drift confirmed against every file under
`src/ScadaLink.CLI/Commands/`. Regenerated the entire "Command Structure" section of
`src/ZB.MOM.WW.ScadaBridge.CLI/Commands/`. Regenerated the entire "Command Structure" section of
`Component-CLI.md` from the actual command tree: all entities are now keyed by integer
`--id`; the non-existent `--file` option is removed; create/update commands list their
real individual flags; non-existent commands (`template diff`, `instance
bind-connections`/`assign-area`, `data-connection assign/unassign`, `security api-key
enable/disable`) are removed; previously-omitted commands (`instance alarm-override
set/delete/list`, `external-system method` subgroup, `site deploy-artifacts`) are added.
A note now points to `src/ScadaLink.CLI/README.md` as the authoritative reference. The
A note now points to `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` as the authoritative reference. The
Configuration section also documents the new `SCADALINK_USERNAME`/`SCADALINK_PASSWORD`
env vars (see CLI-006).
@@ -397,7 +397,7 @@ env vars (see CLI-006).
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Program.cs:10-11`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:60` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs:10-11`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:60` |
**Description**
@@ -426,7 +426,7 @@ rejected by `System.CommandLine` with a clear parse error. Regression tests in
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `docs/requirements/Component-CLI.md:238-249`, `src/ScadaLink.CLI/Commands/CommandHelpers.cs:75` |
| Location | `docs/requirements/Component-CLI.md:238-249`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:75` |
**Description**
@@ -469,7 +469,7 @@ surface, and the CLI's exit-code behaviour itself is now correct and pinned by t
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:181-189` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs:181-189` |
**Description**
@@ -504,7 +504,7 @@ both paths. Regression tests in `DebugStreamTests` (`ClassifyConnectFailure_*`).
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:89` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs:89` |
**Description**
@@ -535,7 +535,7 @@ covered indirectly by the `DebugStreamTests` exit-path tests.
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/DebugCommands.cs:208-227` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs:208-227` |
**Description**
@@ -575,7 +575,7 @@ solely through this helper. Regression tests in `DebugStreamTests`
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.CLI.Tests/` (vs. `src/ScadaLink.CLI/ManagementHttpClient.cs`, `src/ScadaLink.CLI/Commands/DebugCommands.cs`, `src/ScadaLink.CLI/Commands/InstanceCommands.cs:55-58`) |
| Location | `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/` (vs. `src/ZB.MOM.WW.ScadaBridge.CLI/ManagementHttpClient.cs`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs:55-58`) |
**Description**
@@ -623,7 +623,7 @@ The CLI test suite went from 42 to 77 passing tests.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/TemplateCommands.cs:77`, `src/ScadaLink.CLI/Commands/SiteCommands.cs:86`, `src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs:40-42`, `src/ScadaLink.CLI/Commands/DataConnectionCommands.cs:39-40`, `src/ScadaLink.CLI/Commands/NotificationCommands.cs:40-41`, `src/ScadaLink.CLI/Commands/ApiMethodCommands.cs:79` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:77`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SiteCommands.cs:86`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ExternalSystemCommands.cs:40-42`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DataConnectionCommands.cs:39-40`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs:40-41`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ApiMethodCommands.cs:79` |
**Re-triage 2026-05-17:** the finding was originally filed as a Medium "Correctness &
logic bugs" issue, but verification against the Commons message contracts shows the
@@ -682,7 +682,7 @@ entity. Option (a) matches the documented surface and the typical CLI expectatio
Resolved 2026-05-17 (commit pending) per recommendation option (b). Verification of the
Commons `Update*Command` records confirmed whole-replace is the intentional contract, so
the CLI's `Required = true` flags are correct and were left unchanged. The in-repo
`src/ScadaLink.CLI/README.md` — which is authoritative and previously listed every
`src/ZB.MOM.WW.ScadaBridge.CLI/README.md` — which is authoritative and previously listed every
`update` core field as optional `[--name]` — was corrected: the core flags
(`--name`/`--protocol`/`--script`/`--code`/`--emails`/`--endpoint-url`/`--auth-type`/
`--data-type`/`--trigger-type`/`--priority`/`--connection-string`/`--ldap-group`/`--role`)
@@ -701,10 +701,10 @@ surface — that doc-side correction is owned by the docs surface.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `docs/requirements/Component-CLI.md:75`, `docs/requirements/Component-CLI.md:125-126`, `src/ScadaLink.CLI/README.md` (vs. `src/ScadaLink.CLI/Commands/TemplateCommands.cs:404-413`, `src/ScadaLink.CLI/Commands/DataConnectionCommands.cs:41`, `:86`) |
| Location | `docs/requirements/Component-CLI.md:75`, `docs/requirements/Component-CLI.md:125-126`, `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (vs. `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs:404-413`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DataConnectionCommands.cs:41`, `:86`) |
**Re-triage 2026-05-17:** verification found the same two drifts also present in the
in-repo `src/ScadaLink.CLI/README.md` (the authoritative reference): its
in-repo `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (the authoritative reference): its
`template composition delete` section used the non-existent `--template-id` /
`--instance-name` form, and `data-connection create`/`update` documented only
`--configuration` without the canonical `--primary-config` flag (`--configuration` is in
@@ -738,7 +738,7 @@ documented elsewhere.
**Resolution**
Resolved 2026-05-17 (commit pending). Both drifts were present in the in-repo
`src/ScadaLink.CLI/README.md` and were corrected there (the README is this module's
`src/ZB.MOM.WW.ScadaBridge.CLI/README.md` and were corrected there (the README is this module's
authoritative reference): `template composition delete` now documents the real single
`--id <int>` form, and `data-connection create`/`update` now document `--primary-config`
(with the `--configuration` alias noted) alongside `--backup-config` and
@@ -754,7 +754,7 @@ outside this module's editable surface and remains for the docs surface to apply
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/CommandHelpers.cs:184-200` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:184-200` |
**Description**
@@ -794,7 +794,7 @@ first-element-extra column still rendered).
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/BundleCommands.cs:244-289` (vs. `src/ScadaLink.CLI/Commands/CommandHelpers.cs:20-73`, `:159-174`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs:244-289` (vs. `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CommandHelpers.cs:20-73`, `:159-174`) |
**Resolution (2026-05-28):** Extended `CommandHelpers.ExecuteCommandAsync` with optional `timeout` and `onSuccess` parameters so a caller can supply a longer per-command timeout (`BundleCommandTimeout`) and capture the success body for file I/O. The duplicated `RunBundleCommandAsync` was deleted; all three `bundle` sub-commands now delegate through `ExecuteCommandAsync`, which routes the error path through `IsAuthorizationFailure` — exit 2 fires on HTTP 403 OR a `FORBIDDEN`/`UNAUTHORIZED` error code regardless of status, unifying the contract with every other command group.
@@ -835,7 +835,7 @@ messages verbatim.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs:186-193`, `src/ScadaLink.CLI/Commands/AuditExportHelpers.cs:147-153` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditQueryHelpers.cs:186-193`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs:147-153` |
**Description**
@@ -889,7 +889,7 @@ and pass after.
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/BundleCommands.cs:117-124`, `src/ScadaLink.CLI/ManagementHttpClient.cs:47-92` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs:117-124`, `src/ZB.MOM.WW.ScadaBridge.CLI/ManagementHttpClient.cs:47-92` |
**Resolution (2026-05-28):** Replaced the `Convert.FromBase64String(...)` + `File.WriteAllBytes(...)` pair with a new `StreamBase64ToFile(base64, output)` helper that slices the base64 string into 4-char-aligned chunks (1 MB by default) and decodes each chunk straight into a `FileStream` via `Convert.TryFromBase64Chars`. The intermediate `byte[]` and the synchronous full-bundle write are gone — peak working set drops from ~base64-string + ~byte[] + ~envelope-string to ~base64-string + small-chunk-buffer + ~envelope-string. The response body is still buffered into the management envelope string (the `POST /management` wire format does not currently support streaming responses — this finding is bounded by that limit per the recommendation's stop-gap), so a streaming `POST /api/bundle/export` REST endpoint remains a follow-up. Regression tests `BundleCommandsStreamingTests` (6 tests) cover small payload round-trip, multi-chunk boundaries, empty input, invalid base64 → `FormatException`, and argument validation.
@@ -932,7 +932,7 @@ _Unresolved._
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/BundleCommands.cs:117-126` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs:117-126` |
**Resolution (2026-05-28):** Wrapped the `JsonDocument.Parse` + `GetProperty` extraction in a `try/catch (JsonException or KeyNotFoundException or InvalidOperationException)` block and the `StreamBase64ToFile` call in a separate `try/catch (FormatException)`. Either failure now emits a clean `OutputFormatter.WriteError(..., "INVALID_RESPONSE")` and returns exit 1, matching the graceful-degradation pattern established by CLI-002 / CLI-003 / CLI-005. A malformed/abbreviated envelope no longer terminates the CLI with a raw stack trace.
@@ -968,9 +968,9 @@ regression test against a malformed-envelope stub `HttpMessageHandler`.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/CliConfig.cs:41-53` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CLI/CliConfig.cs:41-53` |
**Resolution (2026-05-28):** Wrapped the `File.ReadAllText` + `JsonSerializer.Deserialize` calls in a `try/catch` for `JsonException`/`IOException`/`UnauthorizedAccessException` that prints one warning to `Console.Error` and falls through with default values, so command-line and env-var precedence still works against a malformed `~/.scadalink/config.json`. Regression test `CliConfigTests.Load_MalformedConfigFile_DoesNotThrow_WarnsAndReturnsDefault` redirects `HOME`/`USERPROFILE` to a temp dir containing invalid JSON, asserts no throw, defaulted values, and the stderr warning.
**Resolution (2026-05-28):** Wrapped the `File.ReadAllText` + `JsonSerializer.Deserialize` calls in a `try/catch` for `JsonException`/`IOException`/`UnauthorizedAccessException` that prints one warning to `Console.Error` and falls through with default values, so command-line and env-var precedence still works against a malformed `~/.scadabridge/config.json`. Regression test `CliConfigTests.Load_MalformedConfigFile_DoesNotThrow_WarnsAndReturnsDefault` redirects `HOME`/`USERPROFILE` to a temp dir containing invalid JSON, asserts no throw, defaulted values, and the stderr warning.
**Description**
@@ -987,7 +987,7 @@ if (File.Exists(configPath))
}
```
Neither call is guarded. If `~/.scadalink/config.json` exists but is malformed
Neither call is guarded. If `~/.scadabridge/config.json` exists but is malformed
(stale, partial, or someone's `vim` swap), `JsonSerializer.Deserialize` throws
`JsonException`. If the file exists but isn't readable (mode 0000),
`File.ReadAllText` throws `UnauthorizedAccessException`. Either fault aborts every
@@ -1000,7 +1000,7 @@ input on the command line and don't need the config file at all (`--url`,
Wrap the file-read and the `JsonSerializer.Deserialize` in a single
`try/catch (Exception)` (or specifically `JsonException` +
`UnauthorizedAccessException` + `IOException`). On failure, write a single one-line
warning to `Console.Error` ("ignoring malformed `~/.scadalink/config.json`: {message}")
warning to `Console.Error` ("ignoring malformed `~/.scadabridge/config.json`: {message}")
and return the default `CliConfig`, so the rest of the precedence chain (env vars +
command-line flags) still works.
@@ -1015,7 +1015,7 @@ _Unresolved._
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.CLI.Tests/CommandTreeTests.cs:21-37`, `:55-58` (vs. `src/ScadaLink.CLI/Program.cs:21-36`) |
| Location | `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs:21-37`, `:55-58` (vs. `src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs:21-36`) |
**Resolution (2026-05-28):** Added `AuditCommands.Build` and `BundleCommands.Build` to `AllCommandGroups()`, bumped the count assertion to `Equal(16, …)` with a maintenance comment, and added three new sub-command-surface tests (`AllCommandGroups_Contains_AuditAndBundle`, `AuditCommandGroup_HasQueryExportAndVerifyChain`, `BundleCommandGroup_HasExportPreviewAndImport`). `CommandPayloadTypes_ResolveViaRegistry` now also pins `ExportBundleCommand` / `PreviewBundleCommand` / `ImportBundleCommand` through `ManagementCommandRegistry`.
@@ -1047,13 +1047,13 @@ add a `BundleCommandsTests` file covering the success-envelope parse and the
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `docs/requirements/Component-CLI.md:310-311` (vs. `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs:186`, `src/ScadaLink.CLI/Commands/AuditExportHelpers.cs:126`, `src/ScadaLink.CLI/ManagementHttpClient.cs:94-156`) |
| Location | `docs/requirements/Component-CLI.md:310-311` (vs. `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditQueryHelpers.cs:186`, `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs:126`, `src/ZB.MOM.WW.ScadaBridge.CLI/ManagementHttpClient.cs:94-156`) |
**Resolution (2026-05-28):** Updated `Component-CLI.md` Dependencies bullets — the Management Service (#18) bullet now says the `scadalink audit` group rides a parallel REST surface (`GET /api/audit/query` / `GET /api/audit/export`) sharing HTTP Basic Auth with `/management` but bypassing the actor; the Audit Log (#23) bullet names the specific endpoints and the server-side `AuditEndpoints` permission enforcement (`OperationalAudit` / `AuditExport`).
**Resolution (2026-05-28):** Updated `Component-CLI.md` Dependencies bullets — the Management Service (#18) bullet now says the `scadabridge audit` group rides a parallel REST surface (`GET /api/audit/query` / `GET /api/audit/export`) sharing HTTP Basic Auth with `/management` but bypassing the actor; the Audit Log (#23) bullet names the specific endpoints and the server-side `AuditEndpoints` permission enforcement (`OperationalAudit` / `AuditExport`).
**Description**
`Component-CLI.md:310` states: "The `scadalink audit` command group rides this same
`Component-CLI.md:310` states: "The `scadabridge audit` command group rides this same
transport — there is no separate audit endpoint." But the implementation calls a
new REST surface — `GET /api/audit/query` and `GET /api/audit/export` — via two new
methods on `ManagementHttpClient` (`SendGetAsync`, `SendGetStreamAsync`), distinct
+48 -48
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.CentralUI` |
| Module | `src/ZB.MOM.WW.ScadaBridge.CentralUI` |
| Design doc | `docs/requirements/Component-CentralUI.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -131,7 +131,7 @@ a UX/design adherence gap), and the un-tested `TransportImport` /
| Severity | Critical |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:171-424` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:171-424` |
**Description**
@@ -184,7 +184,7 @@ the commit whose message references `CentralUI-001`.
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs:63-69`; `src/ScadaLink.CentralUI/Components/Pages/Deployment/*.razor` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs:63-69`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/*.razor` |
**Description**
@@ -231,7 +231,7 @@ message references `CentralUI-002`.
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:359-423` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:359-423` |
**Description**
@@ -276,7 +276,7 @@ pre-fix code and pass after. Fixed by the commit whose message references
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs:22-28` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CookieAuthenticationStateProvider.cs:22-28` |
**Description**
@@ -324,7 +324,7 @@ Fixed by the commit whose message references `CentralUI-004`.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs:47-81`; `src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor:18-30` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs:47-81`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/SessionExpiry.razor:18-30` |
**Description**
@@ -346,7 +346,7 @@ fixed 30-minute model. The code and the documented decision must agree.
Resolved 2026-05-16 (commit `<pending>`) — cross-module fix (CentralUI +
Security), explicitly authorized. Root cause confirmed against the source:
`AddCookie` (`ScadaLink.Security/ServiceCollectionExtensions.cs`) set neither
`AddCookie` (`ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs`) set neither
`ExpireTimeSpan` nor `SlidingExpiration`; `AuthEndpoints` stamped a fixed
`expires_at = UtcNow + 30 min` claim and a 30-minute absolute cookie
`ExpiresUtc`; `SessionExpiry.razor` scheduled one hard redirect at that fixed
@@ -383,8 +383,8 @@ Regression tests fail against the pre-fix code and pass after. Security:
`AddSecurity_AuthCookie_ExpireTimeSpanIsConfigurable` (pins the options-pattern
binding). CentralUI: `SessionExpiryPolicyTests.BuildSignInProperties_DoesNotSetFixedAbsoluteExpiry`,
`..._IsPersistent`, `..._AllowsSlidingRefresh` pin that the login sign-in no
longer imposes a fixed absolute cap. `dotnet build ScadaLink.slnx` clean;
`tests/ScadaLink.Security.Tests` 57 passed, `tests/ScadaLink.CentralUI.Tests`
longer imposes a fixed absolute cap. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` clean;
`tests/ZB.MOM.WW.ScadaBridge.Security.Tests` 57 passed, `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests`
254 passed.
### CentralUI-006 — Deployment status page polls every 10s despite the documented SignalR-push design
@@ -394,7 +394,7 @@ longer imposes a fixed absolute cap. `dotnet build ScadaLink.slnx` clean;
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor:196-216` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor:196-216` |
**Description**
@@ -424,7 +424,7 @@ deployment records (`GetAllDeploymentRecordsAsync`) and the full instance map
(`GetAllInstancesAsync`) — contradicting Component-CentralUI "Real-Time Updates"
("transitions push to the UI immediately via SignalR … no polling required").
**Process/DI topology confirmed.** `ScadaLink.Host/Program.cs` calls both
**Process/DI topology confirmed.** `ZB.MOM.WW.ScadaBridge.Host/Program.cs` calls both
`AddDeploymentManager()` (line 75) and `AddCentralUI()` (line 77) on the same
`builder.Services` — DeploymentManager and the Central UI run **in the same
central Host process**, so a DI singleton is genuinely shared between the
@@ -432,7 +432,7 @@ DeploymentManager services and the Blazor circuit's scoped components. The
shared-singleton seam is real; no out-of-process fallback was needed.
**What was implemented — push-based updates.** A new
`IDeploymentStatusNotifier` (`ScadaLink.DeploymentManager/IDeploymentStatusNotifier.cs`)
`IDeploymentStatusNotifier` (`ZB.MOM.WW.ScadaBridge.DeploymentManager/IDeploymentStatusNotifier.cs`)
with a C# `event Action<DeploymentStatusChange>` and a small payload
(`DeploymentStatusChange` = deployment id + instance id + new status). Its
implementation `DeploymentStatusNotifier` invokes each subscriber in isolation
@@ -464,9 +464,9 @@ Regression tests fail against the pre-fix code and pass after. DeploymentManager
pins the shared-singleton seam. CentralUI (`DeploymentsPushUpdateTests`):
`Deployments_DoesNotPoll_HasNoRefreshTimer` (pre-fix: the `_refreshTimer` field
existed — confirmed failing), `Deployments_StatusChange_TriggersReload`, and
`Deployments_Dispose_UnsubscribesFromNotifier`. `dotnet build ScadaLink.slnx`
clean (0 warnings); `tests/ScadaLink.DeploymentManager.Tests` 76 passed,
`tests/ScadaLink.CentralUI.Tests` 257 passed. (`TopologyPageTests`' DI fixture
`Deployments_Dispose_UnsubscribesFromNotifier`. `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
clean (0 warnings); `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests` 76 passed,
`tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests` 257 passed. (`TopologyPageTests`' DI fixture
was also updated to register the new notifier, since it constructs the real
`DeploymentService`.)
@@ -477,7 +477,7 @@ was also updated to register the new notifier, since it constructs the real
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor:69-78`; `src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor:2`; `src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor:2` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor:69-78`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor:2`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor:2` |
**Description**
@@ -520,7 +520,7 @@ fail against the pre-fix code and pass after;
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor:242-243` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AuditLog.razor:242-243` |
**Description**
@@ -563,7 +563,7 @@ time-range filters, so it is unaffected.
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor:400-409,538-544` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor:400-409,538-544` |
**Description**
@@ -608,7 +608,7 @@ mechanism rather than the race window.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Shared/ToastNotification.razor:62-71,90` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ToastNotification.razor:62-71,90` |
**Description**
@@ -651,7 +651,7 @@ still-works behaviours.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor:89-95,151-157` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DiffDialog.razor:89-95,151-157` |
**Description**
@@ -687,7 +687,7 @@ path.
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor:196-205` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor:196-205` |
**Description**
@@ -722,7 +722,7 @@ LoadData_GroupsConnectionsBySite_AndRendersThem}` fail against the pre-fix code
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:951-952` (actual call at `:975`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:951-952` (actual call at `:975`) |
**Description**
@@ -764,7 +764,7 @@ source). The five existing `Hover`/`SignatureHelp` tests in
| Severity | Low (re-triaged from Medium 2026-05-16 — see Resolution) |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:254-259`; `src/ScadaLink.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs:26-117` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:254-259`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxHostHelpers.cs:26-117` |
**Description**
@@ -814,7 +814,7 @@ cannot silently regress.
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Won't Fix |
| Location | `src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs:24`; `src/ScadaLink.CentralUI/Components/Shared/DialogService.cs:18-69` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs:24`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs:18-69` |
**Description**
@@ -860,7 +860,7 @@ service cannot silently regress.
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Shared/DataTable.razor:62-68`; `src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor:167-173` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DataTable.razor:62-68`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor:167-173` |
**Description**
@@ -898,7 +898,7 @@ includes first/last) and `PagerWindowTests` (6 tests pinning the helper logic).
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs:127-138` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs:127-138` |
**Description**
@@ -939,7 +939,7 @@ the pre-auth login exemption was not over-corrected.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor:116-118,123,142,164,170,176,182,189`; `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor:129,139`; `src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor:316-319` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/MonacoEditor.razor:116-118,123,142,164,170,176,182,189`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor:129,139`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor:316-319` |
**Description**
@@ -991,7 +991,7 @@ not logged).
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.CentralUI.Tests/` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/` |
**Description**
@@ -1028,7 +1028,7 @@ Toast/timer disposal: `ToastNotificationTests` (from CentralUI-010).
This batch also added `BrowserTimeTests`, `MonitoringAuthorizationTests`,
`SitesPageTests`, `DataTablePagerTests` + `PagerWindowTests`,
`TreeViewStorageResilienceTests`, and `MonacoEditorLoggingTests`. The
`tests/ScadaLink.CentralUI.Tests` suite is green at 251 tests. Remaining
`tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests` suite is green at 251 tests. Remaining
untested paths are low-risk render-only pages; the Critical/High/Medium paths
the finding prioritised are all now covered, so the finding is considered
resolved. (Note: `TopologyPageTests`'s DI setup was also updated this session —
@@ -1044,7 +1044,7 @@ in the fixture.)
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor:39-62`; `src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs:29-43` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/SessionExpiry.razor:39-62`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/CookieAuthenticationStateProvider.cs:29-43` |
**Description**
@@ -1094,7 +1094,7 @@ expired session (see CentralUI-025).
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor:404-419,511-519,275-289` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor:404-419,511-519,275-289` |
**Description**
@@ -1133,7 +1133,7 @@ critical section as the upsert.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor:221-229,317-322` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor:221-229,317-322` |
**Description**
@@ -1172,7 +1172,7 @@ rather than the whole table on each event.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor:690-698`; `src/ScadaLink.CentralUI/Components/Shared/DiffDialog.razor:107-116,118-130,104` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor:690-698`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DiffDialog.razor:107-116,118-130,104` |
**Description**
@@ -1205,11 +1205,11 @@ call, consistent with the CentralUI-018 fixes in the same module.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor:102`; `src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor:14`; `GetCurrentUserAsync` in `Templates.razor`, `TemplateEdit.razor`, `TemplateCreate.razor`, `SharedScripts.razor`, `SharedScriptForm.razor`, `Sites.razor`, `Topology.razor`, `InstanceCreate.razor`, `InstanceConfigure.razor` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor:102`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Dashboard.razor:14`; `GetCurrentUserAsync` in `Templates.razor`, `TemplateEdit.razor`, `TemplateCreate.razor`, `SharedScripts.razor`, `SharedScriptForm.razor`, `Sites.razor`, `Topology.razor`, `InstanceCreate.razor`, `InstanceConfigure.razor` |
**Description**
`ScadaLink.Security.JwtTokenService` exposes the canonical claim-type constants
`ZB.MOM.WW.ScadaBridge.Security.JwtTokenService` exposes the canonical claim-type constants
(`UsernameClaimType = "Username"`, `DisplayNameClaimType = "DisplayName"`,
`RoleClaimType`, `SiteIdClaimType`). `SiteScopeService` correctly uses
`JwtTokenService.SiteIdClaimType`, but every `GetCurrentUserAsync` helper across
@@ -1239,7 +1239,7 @@ or a small scoped service) so the claim lookup lives in exactly one place.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.CentralUI.Tests/Auth/SessionExpiryPolicyTests.cs`; `src/ScadaLink.CentralUI/Components/Shared/SessionExpiry.razor` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SessionExpiryPolicyTests.cs`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/SessionExpiry.razor` |
**Description**
@@ -1273,7 +1273,7 @@ also forces the CentralUI-020 fix.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor:97-104`; `src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs:56-58,150-178,203-213` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditFilterBar.razor:97-104`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditQueryModel.cs:56-58,150-178,203-213` |
**Description**
@@ -1317,7 +1317,7 @@ in the same time zone in every documented deployment.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:74-80`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:421-425`; `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor:75-81,639-640`; `src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor:62-73,261-262` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:74-80`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:421-425`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor:75-81,639-640`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor:62-73,261-262` |
**Description**
@@ -1364,7 +1364,7 @@ changes.
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor:2,434,472,502`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:2,52-59`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:97-110,201,250-251,278-279` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor:2,434,472,502`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor:2,52-59`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:97-110,201,250-251,278-279` |
**Description**
@@ -1420,7 +1420,7 @@ Regression test `SiteCallsReportPageTests.SiteScoping_ScopedDeploymentUser_Hides
seeds a Deployment user with a single `SiteId=1` claim, asserts only the Plant-A row
renders, and verifies the Plant-B row is dropped (the page's row count drops from 2 to
1). All three existing report-page test fixtures register `SiteScopeService` so the
default system-wide path is unaffected — the full `ScadaLink.CentralUI.Tests` suite
default system-wide path is unaffected — the full `ZB.MOM.WW.ScadaBridge.CentralUI.Tests` suite
still passes (568 / 568).
### CentralUI-029 — `ConfigurationAuditLog` uses `JS.InvokeAsync<int>("eval", ...)` instead of a dedicated JS module
@@ -1430,9 +1430,9 @@ still passes (568 / 568).
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor:248-263` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor:248-263` |
**Resolution (2026-05-28):** Added a small 5-line `wwwroot/js/browser-time.js` ES module exporting `getTimezoneOffsetMinutes()`, and replaced the `JS.InvokeAsync<int>("eval", "new Date().getTimezoneOffset()")` call in `ConfigurationAuditLog.OnAfterRenderAsync` with a lazy `IJSObjectReference` import (`./_content/ScadaLink.CentralUI/js/browser-time.js`) + `module.InvokeAsync<int>("getTimezoneOffsetMinutes")`, matching the `session-expiry.js` / `audit-grid.js` / `nav-state.js` / `transport.js` module-import pattern. The residual `eval` JS-interop surface is gone and the page is now CSP-compatible with `unsafe-eval` forbidden.
**Resolution (2026-05-28):** Added a small 5-line `wwwroot/js/browser-time.js` ES module exporting `getTimezoneOffsetMinutes()`, and replaced the `JS.InvokeAsync<int>("eval", "new Date().getTimezoneOffset()")` call in `ConfigurationAuditLog.OnAfterRenderAsync` with a lazy `IJSObjectReference` import (`./_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/browser-time.js`) + `module.InvokeAsync<int>("getTimezoneOffsetMinutes")`, matching the `session-expiry.js` / `audit-grid.js` / `nav-state.js` / `transport.js` module-import pattern. The residual `eval` JS-interop surface is gone and the page is now CSP-compatible with `unsafe-eval` forbidden.
**Description**
@@ -1464,7 +1464,7 @@ plumbing CentralUI-027 will need.
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/SandboxConsoleCapture.cs:31-118`; `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:401-404` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxConsoleCapture.cs:31-118`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:401-404` |
**Resolution (2026-05-28):** Wrapped every `Write`/`WriteLine` override in `SandboxConsoleCapture` through a `WriteSynchronized` helper that takes a `lock` on the current `AsyncLocal` capture buffer before writing — concurrent `Console.WriteLine` calls from a script's `Task.WhenAll`/`Task.Run` fan-out now serialise on the buffer instance, so the `StringBuilder` underneath can no longer be corrupted. The fall-through to the unwrapped `_fallback` writer is unlocked because the BCL's process-wide `Console.Out` is already synchronised. Different capture scopes have different lock targets, so two unrelated sandbox runs never block each other. New regression test `SandboxConsoleCaptureTests.BeginCapture_ConcurrentWritesFromTasks_DoNotCorruptBuffer` drives 32 tasks × 50 lines each through one capture scope and asserts every line is intact in the buffer.
@@ -1501,9 +1501,9 @@ the expected line count regardless of thread interleaving.
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs:72,104-142,160-161` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs:72,104-142,160-161` |
**Resolution (2026-05-28):** Replaced the `private byte[]? _bundleBytes` field with `private string? _bundleTempPath`. `OnFileSelectedAsync` now creates `Path.GetTempPath()/scadalink-transport-staging/` (created on first use) and streams the upload via `InputFile.OpenReadStream(maxBytes).CopyToAsync(FileStream)` straight to a `Guid.NewGuid():N + .scadabundle` temp file; `TryLoadAsync` opens the same path as a fresh `FileStream` for each `IBundleImporter.LoadAsync` call. The component now implements `IDisposable` and a `DeleteBundleTempFile()` helper that runs on `ResetSessionState`, `OnFileSelectedAsync` (before a new upload), and `Dispose` (circuit teardown); IO failures during cleanup are swallowed so audit-failure-style defensive semantics hold. Per-circuit working set drops from up to `MaxBundleSizeMb` (default 100 MB) per open wizard to the 80 KB FileStream buffer. The existing reflection-based test helper `SeedAtPassphraseStep` was migrated to write bytes to a real temp file and set `_bundleTempPath`, so the 7 existing TransportImport bUnit tests still pass against the new staging model.
**Resolution (2026-05-28):** Replaced the `private byte[]? _bundleBytes` field with `private string? _bundleTempPath`. `OnFileSelectedAsync` now creates `Path.GetTempPath()/scadabridge-transport-staging/` (created on first use) and streams the upload via `InputFile.OpenReadStream(maxBytes).CopyToAsync(FileStream)` straight to a `Guid.NewGuid():N + .scadabundle` temp file; `TryLoadAsync` opens the same path as a fresh `FileStream` for each `IBundleImporter.LoadAsync` call. The component now implements `IDisposable` and a `DeleteBundleTempFile()` helper that runs on `ResetSessionState`, `OnFileSelectedAsync` (before a new upload), and `Dispose` (circuit teardown); IO failures during cleanup are swallowed so audit-failure-style defensive semantics hold. Per-circuit working set drops from up to `MaxBundleSizeMb` (default 100 MB) per open wizard to the 80 KB FileStream buffer. The existing reflection-based test helper `SeedAtPassphraseStep` was migrated to write bytes to a real temp file and set `_bundleTempPath`, so the 7 existing TransportImport bUnit tests still pass against the new staging model.
**Description**
@@ -1536,7 +1536,7 @@ docs to call out the in-memory cost per concurrent import session.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor:76-82`; `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs:65,196-197,219-220` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor:76-82`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor.cs:65,196-197,219-220` |
**Resolution (2026-05-28):** Added a `Stack<AuditLogPaging?> _cursorStack` and `AuditLogPaging? _currentPaging` field to `AuditResultsGrid.razor.cs`. `NextPage` now pushes the current cursor before advancing; a new `PrevPage` method pops the prior cursor, reloads at that position, and decrements `_pageNumber` only if the reload succeeds (a failed fetch leaves the user on the current page rather than stranding them between pages). The filter-change reset clears the stack alongside `_rows`. The razor template now renders a `btn-group` with a Previous button (gated on `CanGoBack`) alongside the existing Next button; both buttons get the standard `disabled` treatment during loads.
@@ -1568,9 +1568,9 @@ forward-only paging on the Audit Log grid.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs:97-238,267-319`; `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:107-148`; `tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs`; `tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs:97-238,267-319`; `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs:107-148`; `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs`; `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs` |
**Resolution (2026-05-28):** Added `tests/ScadaLink.CentralUI.Tests/Pages/QueryStringDrillInTests.cs` (4 bUnit tests). For `SiteCallsReport` it pins the case-insensitive `?status=parked` → canonical "Parked" normalisation, the unrecognised-status silent drop, and the non-boolean `?stuck=yes` silent drop — gaps the existing `SiteCallsReportPageTests` (which covered the Parked / stuck=true / no-params happy paths) did not exercise. For `TransportImport` it asserts that the wizard has no `[Parameter]`-bound query keys: an unrecognised drill-in URL (`?bundleImportId=…&foo=bar`) leaves `_step` at `Upload` and the Step-1 InputFile control renders cleanly.
**Resolution (2026-05-28):** Added `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs` (4 bUnit tests). For `SiteCallsReport` it pins the case-insensitive `?status=parked` → canonical "Parked" normalisation, the unrecognised-status silent drop, and the non-boolean `?stuck=yes` silent drop — gaps the existing `SiteCallsReportPageTests` (which covered the Parked / stuck=true / no-params happy paths) did not exercise. For `TransportImport` it asserts that the wizard has no `[Parameter]`-bound query keys: an unrecognised drill-in URL (`?bundleImportId=…&foo=bar`) leaves `_step` at `Upload` and the Step-1 InputFile control renders cleanly.
**Description**
+64 -64
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.ClusterInfrastructure` |
| Module | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure` |
| Design doc | `docs/requirements/Component-ClusterInfrastructure.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -40,7 +40,7 @@ well-documented, and well-tested. This re-review examined all three source files
all three test files against the full 10-category checklist and found **two new
issues**, both stemming from work the prior review explicitly deferred to a "Host
review" that has not happened: the `DownIfAlone` property is exposed and validated as
part of the configuration contract but is never consumed — `ScadaLink.Host`'s
part of the configuration contract but is never consumed — `ZB.MOM.WW.ScadaBridge.Host`'s
`BuildHocon` still hard-codes `down-if-alone = on` (CI-009, Medium) — and the validator
does not enforce the design doc's requirement that `down-if-alone` be `on` for the
keep-oldest resolver, so `DownIfAlone = false` is silently accepted (CI-010, Low).
@@ -58,9 +58,9 @@ either did not surface or that have aged into the file:
- **CI-011 (Low, Code organization)**`ClusterOptions.SectionName` is
documented as "the single source of truth so binding sites do not hard-code
the magic string" (the very justification CI-005's resolution offered), but
`ScadaLink.Host.SiteServiceRegistration.BindSharedOptions:100` and three
references in `ScadaLink.Host.StartupValidator` all hard-code
`"ScadaLink:Cluster"` literals. The constant is decorative — a "single source
`ZB.MOM.WW.ScadaBridge.Host.SiteServiceRegistration.BindSharedOptions:100` and three
references in `ZB.MOM.WW.ScadaBridge.Host.StartupValidator` all hard-code
`"ScadaBridge:Cluster"` literals. The constant is decorative — a "single source
of truth" that nothing reads. Same pattern as CI-009 (inert configuration knob).
- **CI-012 (Low, Design-document adherence)** — the validator accepts
`SeedNodes.Count == 1` even though the design doc states "both nodes are seed
@@ -90,7 +90,7 @@ Original review (2026-05-16, `9c60592`) below; the re-review notes (2026-05-17,
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ✓ | No executable logic exists beyond an options POCO; no logic bugs, but `ServiceCollectionExtensions` returns success while doing nothing (CI-002). **Re-review:** CI-002 resolved. New — `DownIfAlone` is a settable property that controls nothing because the HOCON builder hard-codes the value (CI-009). |
| 2 | Akka.NET conventions | ✓ | No actors, no `ActorSystem` bootstrap, no supervision, no cluster/singleton wiring exist despite the design doc requiring all of them (CI-001). Nothing to assess against `Tell`/`Ask`, immutability, or `PipeTo`. **Re-review:** confirmed the Akka bootstrap legitimately lives in `ScadaLink.Host` (CI-001 resolution); still nothing actor-related in this module. No issues. |
| 2 | Akka.NET conventions | ✓ | No actors, no `ActorSystem` bootstrap, no supervision, no cluster/singleton wiring exist despite the design doc requiring all of them (CI-001). Nothing to assess against `Tell`/`Ask`, immutability, or `PipeTo`. **Re-review:** confirmed the Akka bootstrap legitimately lives in `ZB.MOM.WW.ScadaBridge.Host` (CI-001 resolution); still nothing actor-related in this module. No issues. |
| 3 | Concurrency & thread safety | ✓ | No shared mutable state, no actors, no async code. No issues found in current code. **Re-review:** validator and DI extensions are stateless; no issues. |
| 4 | Error handling & resilience | ✓ | Failover, split-brain, dual-node recovery, and graceful-shutdown logic are entirely absent (CI-001). No exception paths to review in current code. **Re-review:** the validator now fails fast on misconfiguration. New — it does not enforce the design doc's `down-if-alone = on` requirement (CI-010). |
| 5 | Security | ✓ | No authn/authz surface in this module. Akka remoting is unconfigured, so transport security cannot be assessed; flagged as part of the missing implementation (CI-001). No secret handling present. **Re-review:** still no authn/authz surface, no secret handling. No issues. |
@@ -124,7 +124,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:9`, `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ServiceCollectionExtensions.cs:9`, `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ServiceCollectionExtensions.cs:16` |
**Description**
@@ -138,8 +138,8 @@ and a `ServiceCollectionExtensions` whose methods are explicitly commented
and simply return the unmodified `IServiceCollection`. There is no `Akka.Cluster`,
`Akka.Cluster.Tools`, `Akka.Remote`, or split-brain-resolver dependency in the
`.csproj` at all (it references only `Microsoft.Extensions.DependencyInjection.Abstractions`,
`Microsoft.Extensions.Options`, and `ScadaLink.Commons`). Because every other
ScadaLink component runs inside the actor system this module is responsible for
`Microsoft.Extensions.Options`, and `ZB.MOM.WW.ScadaBridge.Commons`). Because every other
ScadaBridge component runs inside the actor system this module is responsible for
creating, the absence of any implementation blocks the foundational layer of the
system.
@@ -157,19 +157,19 @@ should clearly state it is unimplemented so callers do not assume otherwise.
_Re-triaged 2026-05-16 — remains Open, needs a design decision from the user._
Verified against the source at the reviewed commit: the finding's factual claims hold.
`src/ScadaLink.ClusterInfrastructure` still contains only `ClusterOptions.cs` and a
`src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure` still contains only `ClusterOptions.cs` and a
no-op `ServiceCollectionExtensions.cs`, and the `.csproj` references no Akka packages.
However, the documented cluster behaviour is **not actually absent from the system**
it has been implemented in the **Host** project rather than in this module:
- `src/ScadaLink.Host/Actors/AkkaHostedService.cs` bootstraps the `ActorSystem`,
generates the HOCON from `ClusterOptions` (it imports `ScadaLink.ClusterInfrastructure`
- `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` bootstraps the `ActorSystem`,
generates the HOCON from `ClusterOptions` (it imports `ZB.MOM.WW.ScadaBridge.ClusterInfrastructure`
and injects `IOptions<ClusterOptions>`), and configures the `keep-oldest` split-brain
resolver with `down-if-alone = on` (see `AkkaHostedService.cs:95-96`).
- `src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs`, `AkkaClusterNodeProvider.cs`,
- `src/ZB.MOM.WW.ScadaBridge.Host/Health/AkkaClusterHealthCheck.cs`, `AkkaClusterNodeProvider.cs`,
and `Health/ActiveNodeHealthCheck.cs` cover cluster membership / active-node detection.
- Akka cluster/remote package references live in `ScadaLink.Host.csproj` and the
- Akka cluster/remote package references live in `ZB.MOM.WW.ScadaBridge.Host.csproj` and the
per-component projects (`SiteRuntime`, `Communication`, etc.).
So the real situation is an **ownership / design-doc drift**, not missing behaviour:
@@ -183,11 +183,11 @@ of two substantial decisions, both requiring the user:
1. **Move the bootstrap into this module** — relocate the HOCON generation, split-brain
config, cluster-singleton helpers and `CoordinatedShutdown` wiring out of
`ScadaLink.Host` into `ScadaLink.ClusterInfrastructure`, add the Akka package
`ZB.MOM.WW.ScadaBridge.Host` into `ZB.MOM.WW.ScadaBridge.ClusterInfrastructure`, add the Akka package
references, and re-wire the Host to call into it. This is a cross-module refactor
touching `src/ScadaLink.Host/*` and several other projects — outside the edit scope
permitted for this finding (only `src/ScadaLink.ClusterInfrastructure/`,
`tests/ScadaLink.ClusterInfrastructure.Tests/`, and this file may be edited).
touching `src/ZB.MOM.WW.ScadaBridge.Host/*` and several other projects — outside the edit scope
permitted for this finding (only `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/`,
`tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/`, and this file may be edited).
2. **Accept the current placement** — keep the bootstrap in the Host and update
`Component-ClusterInfrastructure.md` (and the README component table) to record that
the Host owns the actor-system/cluster bootstrap and that this module's role is the
@@ -202,8 +202,8 @@ bring-up), and the design docs are corrected to record the true ownership.
**Resolved** — fixing commit `<pending>`, date 2026-05-16. The finding was a design-doc
drift, not missing behaviour. `docs/requirements/Component-ClusterInfrastructure.md` now
carries an "Implementation Note — Code Placement" section stating that the
`ScadaLink.ClusterInfrastructure` project owns the `ClusterOptions` configuration model
while `ScadaLink.Host` owns the Akka bootstrap, HOCON generation, split-brain-resolver
`ZB.MOM.WW.ScadaBridge.ClusterInfrastructure` project owns the `ClusterOptions` configuration model
while `ZB.MOM.WW.ScadaBridge.Host` owns the Akka bootstrap, HOCON generation, split-brain-resolver
wiring, `CoordinatedShutdown` integration, and active-node health checks. The README
component table (row 13) was updated to match. No code change was required — the
documented cluster behaviour already exists and is exercised; only the doc's
@@ -216,7 +216,7 @@ module-ownership claim was wrong. Module test suite green (3 passed).
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:7-17` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ServiceCollectionExtensions.cs:7-17` |
**Description**
@@ -239,17 +239,17 @@ with the genuine registration when CI-001 is addressed.
**Resolution**
Confirmed against the source: both methods returned the `IServiceCollection`
unchanged. Verified the consumers — `ScadaLink.Host` calls `AddClusterInfrastructure()`
unchanged. Verified the consumers — `ZB.MOM.WW.ScadaBridge.Host` calls `AddClusterInfrastructure()`
(`Program.cs:68`, `SiteServiceRegistration.cs:24`); `AddClusterInfrastructureActors`
is dead — it is called nowhere in the solution.
**Resolved** — fixing commit `commit pending`, date 2026-05-16.
`AddClusterInfrastructure` now does real work: it registers the
`ClusterOptionsValidator` (CI-004) via `TryAddEnumerable`, so the method is no longer a
no-op and a misconfigured `ScadaLink:Cluster` section fails fast on the first
no-op and a misconfigured `ScadaBridge:Cluster` section fails fast on the first
`IOptions<ClusterOptions>` resolution. `AddClusterInfrastructureActors` — which this
component never had any actors to register, as CI-001 established the Akka bootstrap
lives in `ScadaLink.Host` — now throws `NotImplementedException` with a message
lives in `ZB.MOM.WW.ScadaBridge.Host` — now throws `NotImplementedException` with a message
pointing the caller to the Host, rather than masquerading as a completed registration.
Covered by `ServiceCollectionExtensionsTests`
(`AddClusterInfrastructure_RegistersOptionsValidator`,
@@ -263,7 +263,7 @@ Covered by `ServiceCollectionExtensionsTests`
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs:3-11` |
**Description**
@@ -290,7 +290,7 @@ agree on where each value lives.
**Resolution**
Partially re-triaged. Verified against the source: most of the "missing" settings are
**deliberately owned by `ScadaLink.Host.NodeOptions`** — `NodeOptions` already carries
**deliberately owned by `ZB.MOM.WW.ScadaBridge.Host.NodeOptions`** — `NodeOptions` already carries
`Role`, `NodeHostname`, `SiteId`, `RemotingPort` and `GrpcPort`, and `AkkaHostedService`
builds the HOCON from `NodeOptions` for exactly those values. Local SQLite storage paths
live in the database / store-and-forward options. This is the ownership split CI-001
@@ -307,7 +307,7 @@ deliberate ownership split — node identity/remoting/gRPC in `Host.NodeOptions`
paths in the database options, cluster-formation settings here — so the design doc and
the options classes now agree on where each value lives. (`AkkaHostedService` currently
hard-codes `down-if-alone = on` in HOCON; wiring it to read `DownIfAlone` is a one-line
`ScadaLink.Host` change, outside this module's permitted edit scope, and is noted for
`ZB.MOM.WW.ScadaBridge.Host` change, outside this module's permitted edit scope, and is noted for
the Host's review.) Covered by `ClusterOptionsTests.DefaultValues_AreCorrect` and
`ClusterOptionsTests.DownIfAlone_CanBeSet`.
@@ -318,7 +318,7 @@ the Host's review.) Covered by `ClusterOptionsTests.DefaultValues_AreCorrect` an
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs:3-11` |
**Description**
@@ -374,7 +374,7 @@ attributes cannot. Covered by `ClusterOptionsValidatorTests` (8 cases) and
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs:3` |
**Description**
@@ -398,9 +398,9 @@ Confirmed against the source: `ClusterOptions` previously exposed no section-nam
constant, leaving binding sites to hard-code the magic string.
**Resolved** — fixing commit `commit pending`, date 2026-05-16. `ClusterOptions` now
exposes `public const string SectionName = "ScadaLink:Cluster";` as the single source
exposes `public const string SectionName = "ScadaBridge:Cluster";` as the single source
of truth for the `appsettings.json` section name, with an XML doc explaining its
purpose. The chosen value matches the `ScadaLink:`-prefixed section convention used by
purpose. The chosen value matches the `ScadaBridge:`-prefixed section convention used by
peer option classes and referenced by `ClusterOptionsValidator` / the design doc.
Covered by `ClusterOptionsTests.SectionName_IsTheExpectedAppSettingsSection`, which
both pins the value and — by referencing the constant — guards against its removal
@@ -413,7 +413,7 @@ both pins the value and — by referencing the constant — guards against its r
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs:1-51` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsTests.cs:1-51` |
**Description**
@@ -438,14 +438,14 @@ from `ClusterOptions` and for the options validation from CI-004.
**Resolution**
Re-triaged in light of CI-001's resolution. The Akka bootstrap, HOCON generation,
cluster formation, failover and singleton handover are owned by `ScadaLink.Host`, not
cluster formation, failover and singleton handover are owned by `ZB.MOM.WW.ScadaBridge.Host`, not
this project — multi-node `Akka.Cluster.TestKit` tests for that behaviour belong in the
Host's test suite, outside this module's scope. What this module legitimately owns is
`ClusterOptions`, its validator, and the DI registration, and the testing gap there is
now closed.
**Resolved** — fixing commit `commit pending`, date 2026-05-16. Added two test classes
to `tests/ScadaLink.ClusterInfrastructure.Tests`: `ClusterOptionsValidatorTests`
to `tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests`: `ClusterOptionsValidatorTests`
(8 cases — valid defaults pass; `MinNrOfMembers != 1`, unsupported split-brain
strategies, empty seed nodes, heartbeat not below the failure threshold, non-positive
`StableAfter` all fail; and a multi-failure accumulation case) and
@@ -467,7 +467,7 @@ design); `ClusterOptionsValidator` is the layer that now rejects `keep-majority`
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:3-11` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs:3-11` |
**Description**
@@ -510,7 +510,7 @@ inspection of `ClusterOptions.cs:3-74`. Module test suite green (17 passed).
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:9`, `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ServiceCollectionExtensions.cs:9`, `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ServiceCollectionExtensions.cs:16` |
**Description**
@@ -540,17 +540,17 @@ complete-looking design doc with no caveat. That premise has been overtaken by t
CI-001/CI-002 work:
- The "Phase 0 skeleton" comments no longer exist anywhere in
`src/ScadaLink.ClusterInfrastructure` (verified by `grep`). `ServiceCollectionExtensions`
`src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure` (verified by `grep`). `ServiceCollectionExtensions`
now does real work (registers `ClusterOptionsValidator`) and `AddClusterInfrastructureActors`
throws explicitly — both with accurate XML docs explaining the ownership split.
- The module is no longer an unimplemented skeleton. CI-001 established that the Akka
bootstrap legitimately lives in `ScadaLink.Host`, and this project's true scope —
bootstrap legitimately lives in `ZB.MOM.WW.ScadaBridge.Host`, and this project's true scope —
the `ClusterOptions` configuration contract, its validator, and DI registration — is
fully implemented and tested.
- The design doc `Component-ClusterInfrastructure.md` now opens with an
"Implementation Note — Code Placement" section (added by CI-001) that explicitly
states the component is a *design responsibility* realised across
`ScadaLink.ClusterInfrastructure` (configuration model) and `ScadaLink.Host`
`ZB.MOM.WW.ScadaBridge.ClusterInfrastructure` (configuration model) and `ZB.MOM.WW.ScadaBridge.Host`
(bootstrap/runtime wiring), and the README component table (row 13) was updated to
match. A reader of the design doc no longer assumes a single fully-built project.
@@ -569,7 +569,7 @@ inspection of `ServiceCollectionExtensions.cs` and
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:74` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs:74` |
**Description**
@@ -577,7 +577,7 @@ The `DownIfAlone` property was added to `ClusterOptions` by CI-003's resolution
part of "the split-brain configuration contract". It is public, defaults to `true`,
carries an XML doc presenting it as "the design-doc requirement", and is exercised by
`ClusterOptionsTests.DownIfAlone_CanBeSet`. However, nothing in the system reads it.
The Akka.NET HOCON is generated by `ScadaLink.Host.Actors.AkkaHostedService.BuildHocon`,
The Akka.NET HOCON is generated by `ZB.MOM.WW.ScadaBridge.Host.Actors.AkkaHostedService.BuildHocon`,
which **hard-codes** the resolver setting:
```
@@ -596,7 +596,7 @@ field (`SeedNodes`, `MinNrOfMembers`, `SplitBrainResolverStrategy`, `StableAfter
The result is a configuration property that an operator can set in `appsettings.json`,
that passes validation, and that has **zero runtime effect** — setting
`DownIfAlone: false` does not turn the flag off. CI-003's resolution explicitly
acknowledged this gap ("wiring it to read `DownIfAlone` is a one-line `ScadaLink.Host`
acknowledged this gap ("wiring it to read `DownIfAlone` is a one-line `ZB.MOM.WW.ScadaBridge.Host`
change ... noted for the Host's review") but the wiring was never done and no tracked
finding carried it, so the gap has silently persisted to commit `39d737e`. An inert,
misleadingly-documented configuration knob is a correctness and design-adherence
@@ -616,13 +616,13 @@ controls nothing.
**Resolution**
Root cause verified against the source at commit `39d737e`:
`src/ScadaLink.Host/Actors/AkkaHostedService.cs:147` hard-codes `down-if-alone = on`
`src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:147` hard-codes `down-if-alone = on`
inside the `keep-oldest` block, and `BuildHocon` consumes every other `ClusterOptions`
field but never reads `clusterOptions.DownIfAlone`. The finding's facts hold. The fix
is correctly scoped to the **Host** module — the configuration property and its
validation legitimately live in `ScadaLink.ClusterInfrastructure` (this module),
validation legitimately live in `ZB.MOM.WW.ScadaBridge.ClusterInfrastructure` (this module),
and the per-CLAUDE.md ownership split (CI-001) places HOCON generation in
`ScadaLink.Host`.
`ZB.MOM.WW.ScadaBridge.Host`.
**Resolved** — by cross-reference, date 2026-05-17. No code change in this module:
`ClusterOptions.DownIfAlone` and its validation (CI-010) are correct and complete here.
@@ -638,7 +638,7 @@ green (18 passed).
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptionsValidator.cs:21-71` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs:21-71` |
**Description**
@@ -688,27 +688,27 @@ confirmed failing, then passing after the fix. Module test suite green (18 passe
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:24-27`, `src/ScadaLink.Host/SiteServiceRegistration.cs:100`, `src/ScadaLink.Host/StartupValidator.cs:43`, `src/ScadaLink.Host/StartupValidator.cs:75` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs:24-27`, `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs:100`, `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs:43`, `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs:75` |
**Resolution (2026-05-28):** Took option (b) since wiring the constant into the Host's `SiteServiceRegistration.BindSharedOptions` / `StartupValidator` is outside this module's editable surface — deleted the `SectionName` constant from `ClusterOptions.cs` and the companion `SectionName_IsTheExpectedAppSettingsSection` test from `ClusterOptionsTests.cs`. The Host's `"ScadaLink:Cluster"` literals now stand alone (consistent with the implementation rather than the broken "single source of truth" claim). A code-comment placeholder records the rationale so a future Host-side change can reinstate the constant alongside the binding-site updates.
**Resolution (2026-05-28):** Took option (b) since wiring the constant into the Host's `SiteServiceRegistration.BindSharedOptions` / `StartupValidator` is outside this module's editable surface — deleted the `SectionName` constant from `ClusterOptions.cs` and the companion `SectionName_IsTheExpectedAppSettingsSection` test from `ClusterOptionsTests.cs`. The Host's `"ScadaBridge:Cluster"` literals now stand alone (consistent with the implementation rather than the broken "single source of truth" claim). A code-comment placeholder records the rationale so a future Host-side change can reinstate the constant alongside the binding-site updates.
**Description**
`ClusterOptions.SectionName` was added by CI-005 as `public const string SectionName =
"ScadaLink:Cluster";`, with an XML doc declaring it "the single source of truth so
"ScadaBridge:Cluster";`, with an XML doc declaring it "the single source of truth so
binding sites do not hard-code the magic string". CI-005's resolution likewise framed
the constant as the canonical reference value. In practice, **no caller in the
solution reads it**. `grep -rn "ClusterOptions.SectionName" src/` returns zero hits.
Every site that needs the section name hard-codes the literal:
- `ScadaLink.Host.SiteServiceRegistration.BindSharedOptions:100`
`services.Configure<ClusterOptions>(config.GetSection("ScadaLink:Cluster"));`
- `ScadaLink.Host.StartupValidator:43,45,75` — three `"ScadaLink:Cluster"` /
`"ScadaLink:Cluster:SeedNodes"` literals.
- `ZB.MOM.WW.ScadaBridge.Host.SiteServiceRegistration.BindSharedOptions:100`
`services.Configure<ClusterOptions>(config.GetSection("ScadaBridge:Cluster"));`
- `ZB.MOM.WW.ScadaBridge.Host.StartupValidator:43,45,75` — three `"ScadaBridge:Cluster"` /
`"ScadaBridge:Cluster:SeedNodes"` literals.
The `SectionName_IsTheExpectedAppSettingsSection` test pins the constant's value but
does not protect against the underlying drift hazard: if someone changes
`SectionName` to `"ScadaLink:Akka:Cluster"`, the test still passes (because it tests
`SectionName` to `"ScadaBridge:Akka:Cluster"`, the test still passes (because it tests
the constant against the same literal), the validator still registers, and binding
silently goes to whichever string the Host hard-codes. The constant currently
provides none of the safety its XML doc claims. This is the same pattern of "inert
@@ -717,7 +717,7 @@ configuration drift rather than runtime behaviour.
**Recommendation**
Either (a) replace the hard-coded `"ScadaLink:Cluster"` literals in
Either (a) replace the hard-coded `"ScadaBridge:Cluster"` literals in
`SiteServiceRegistration.cs:100` and `StartupValidator.cs:43,45,75` with
`ClusterOptions.SectionName` (a small Host-module change, to be tracked there), or
(b) if the constant is intentionally decorative, soften the XML doc so it does not
@@ -731,7 +731,7 @@ guarantee the code does not deliver.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ClusterOptionsValidator.cs:30-43` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs:30-43` |
**Description**
@@ -741,18 +741,18 @@ guarantee the code does not deliver.
> its partner. Either node can start first and form the cluster; the other joins when
> it starts. No startup ordering dependency.
A correctly-configured ScadaLink deployment therefore lists **two** seed nodes.
A correctly-configured ScadaBridge deployment therefore lists **two** seed nodes.
`ClusterOptionsValidator.Validate` only checks that `SeedNodes` is non-null and
non-empty (`Count == 0`). A configuration with a single seed node passes validation
silently — but that defeats the "no startup ordering dependency" guarantee the
design doc explicitly calls out.
`ScadaLink.Host.StartupValidator:43-46` does enforce the rule:
`ZB.MOM.WW.ScadaBridge.Host.StartupValidator:43-46` does enforce the rule:
```csharp
var seedNodes = configuration.GetSection("ScadaLink:Cluster:SeedNodes").Get<List<string>>();
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
if (seedNodes is null || seedNodes.Count < 2)
errors.Add("ScadaLink:Cluster:SeedNodes must have at least 2 entries");
errors.Add("ScadaBridge:Cluster:SeedNodes must have at least 2 entries");
```
So the rule is enforced — but by the **other** project, after the
@@ -789,7 +789,7 @@ ClusterInfrastructure.Tests).
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `tests/ScadaLink.ClusterInfrastructure.Tests/ClusterOptionsTests.cs:47-67` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterOptionsTests.cs:47-67` |
**Resolution (2026-05-28):** Added a 10-line inline `// ClusterInfra-013: ...` block at the top of `Properties_CanBeSetToCustomValues` explicitly recording that this test exercises the POCO property setters only — the `keep-majority` strategy and `MinNrOfMembers = 2` values are explicitly forbidden in production by `ClusterOptionsValidator`, and the comment cross-references `UnsupportedSplitBrainStrategy_FailsValidation` and `MinNrOfMembers_NotOne_FailsValidation` so a future reader cannot misread the test as endorsing those values.
@@ -828,7 +828,7 @@ goal is to make the test's intent self-documenting.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ClusterInfrastructure/ServiceCollectionExtensions.cs:42-48` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ServiceCollectionExtensions.cs:42-48` |
**Resolution (2026-05-28):** Deleted the `AddClusterInfrastructureActors` extension method from `ServiceCollectionExtensions.cs` and its companion `AddClusterInfrastructureActors_ThrowsRatherThanSilentlySucceeding` test from `ServiceCollectionExtensionsTests.cs`. Verified no production caller existed before deletion via `grep -rn`. A code comment records the rationale (CI-001 ownership question now permanently settled; method served only to throw and was IDE-auto-complete noise). The class-level XML doc on the test file was updated to drop the stale reference to the removed test.
@@ -840,7 +840,7 @@ and a body that unconditionally throws `NotImplementedException`. CI-002's resol
chose "throw loudly" over "delete" specifically because CI-001 was still resolving the
ownership-split question. That question is settled — the design doc, the README
component table, and `Component-ClusterInfrastructure.md`'s "Implementation Note — Code
Placement" all permanently locate the Akka actor bootstrap in `ScadaLink.Host`.
Placement" all permanently locate the Akka actor bootstrap in `ZB.MOM.WW.ScadaBridge.Host`.
A `grep -rn "AddClusterInfrastructureActors" src/ tests/` confirms there is no caller
anywhere in the solution. The method's only consumer is its own test
@@ -854,6 +854,6 @@ expecting it to register something), and gives nothing in return.
Delete `AddClusterInfrastructureActors`, delete its test, and add a one-line note to
`docs/requirements/Component-ClusterInfrastructure.md`'s code-placement section
explicitly stating that this project exposes no actor-registration extension
(actor wiring lives in `ScadaLink.Host`). If the user prefers to keep the
(actor wiring lives in `ZB.MOM.WW.ScadaBridge.Host`). If the user prefers to keep the
"fail-fast" trap, mark the method `[Obsolete(true, error: true)]` so the compiler —
not the runtime — rejects the call.
+45 -45
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.Commons` |
| Module | `src/ZB.MOM.WW.ScadaBridge.Commons` |
| Design doc | `docs/requirements/Component-Commons.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -79,8 +79,8 @@ coverage for the new types is uneven — `TrackedOperationId`, `SiteCallOperatio
`Notification`, and `SiteCall` are all directly tested; the Transport types
(`BundleManifest`, `EncryptionMetadata`, `BundleSession`, `BundleSummary`, `ExportSelection`,
`ImportPreview`, `ImportResolution`, `ImportResult`, `ManifestContentEntry`) have only
integration-level coverage in `tests/ScadaLink.Transport.IntegrationTests/`, with no
shape/serialization tests in `ScadaLink.Commons.Tests`.
integration-level coverage in `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/`, with no
shape/serialization tests in `ZB.MOM.WW.ScadaBridge.Commons.Tests`.
## Checklist coverage
@@ -109,7 +109,7 @@ shape/serialization tests in `ScadaLink.Commons.Tests`.
| 6 | Performance & resource management | ✓ | `IBundleSessionStore.EvictExpired` exists for sessions — good. `BundleSession` carries `DecryptedContent` plus `Manifest` per session; the size is bounded by the configured bundle cap but no explicit per-session size accounting. `ExternalCallResult.Response` lazy parse not thread-safe (Commons-021). |
| 7 | Design-document adherence | ✓ | `Component-Commons.md` is now significantly stale relative to the actual file set: stale enum values for `AuditKind`/`AuditStatus`, missing `AuditEvent`/`SiteCall` entities, missing `IAuditLogRepository`, missing six service interfaces and `Interfaces/Transport/`, missing four `Types/*` folders and `Messages/Audit/` (Commons-017). |
| 8 | Code organization & conventions | ✓ | `IOperationTrackingStore` and `IPartitionMaintenance` live at the root of `Interfaces/` rather than under `Interfaces/Services/` (Commons-018). `BundleSession.Locked` uses a magic `3` rather than a named constant (Commons-016). Message contracts and entities otherwise follow the additive-evolution / POCO / `record` conventions. |
| 9 | Testing coverage | ✓ | Transport types (`BundleManifest`, `EncryptionMetadata`, `BundleSession`, `BundleSummary`, `ExportSelection`, `ImportPreview`, `ImportResolution`, `ImportResult`, `ManifestContentEntry`) have no unit tests in `tests/ScadaLink.Commons.Tests/`; only `tests/ScadaLink.Transport.IntegrationTests/` exercises them (Commons-020). `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` / `UpsertSiteCallCommand` / `PullAuditEventsRequest` / `PullAuditEventsResponse` / `AuditTelemetryEnvelope` shape tests also absent. |
| 9 | Testing coverage | ✓ | Transport types (`BundleManifest`, `EncryptionMetadata`, `BundleSession`, `BundleSummary`, `ExportSelection`, `ImportPreview`, `ImportResolution`, `ImportResult`, `ManifestContentEntry`) have no unit tests in `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/`; only `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/` exercises them (Commons-020). `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` / `UpsertSiteCallCommand` / `PullAuditEventsRequest` / `PullAuditEventsResponse` / `AuditTelemetryEnvelope` shape tests also absent. |
| 10 | Documentation & comments | ✓ | `IAuditCorrelationContext` references `BundleImporter.ApplyAsync` — an implementation type Commons does not see, so the `<see cref>` is unresolvable (Commons-022b, folded into Commons-022). `ImportPreviewItem.FieldDiffJson` and `Notification.ResolvedTargets` are JSON-string columns with no documented shape contract (Commons-022). |
## Findings
@@ -121,7 +121,7 @@ shape/serialization tests in `ScadaLink.Commons.Tests`.
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/StaleTagMonitor.cs:42-46`, `:62-67` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/StaleTagMonitor.cs:42-46`, `:62-67` |
**Description**
@@ -162,7 +162,7 @@ internal `CallbackEnteredHook` test seam).
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/DynamicJsonElement.cs:10-17` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/DynamicJsonElement.cs:10-17` |
**Description**
@@ -200,7 +200,7 @@ remarks block documenting the lifetime contract. Regression tests added in
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/ScriptParameters.cs:72-86` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/ScriptParameters.cs:72-86` |
**Description**
@@ -241,11 +241,11 @@ contract is unchanged. Regression tests added in `ScriptParametersTests`
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Messages/Management/ManagementCommandRegistry.cs:14-35` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementCommandRegistry.cs:14-35` |
**Description**
`BuildRegistry` registers only types in the exact `ScadaLink.Commons.Messages.Management`
`BuildRegistry` registers only types in the exact `ZB.MOM.WW.ScadaBridge.Commons.Messages.Management`
namespace whose names end in `Command`. `GetCommandName(Type)`, however, strips a
`Command` suffix from *any* type passed to it. The two halves disagree:
@@ -295,7 +295,7 @@ SiteRuntime all build clean against the change. Regression tests added in
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs:25-51` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/OpcUaEndpointConfigSerializer.cs:25-51` |
**Description**
@@ -338,7 +338,7 @@ out-of-scope to change. Regression tests added in `OpcUaEndpointConfigSerializer
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/DynamicJsonElement.cs:47-51`, `:66-76` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/DynamicJsonElement.cs:47-51`, `:66-76` |
**Description**
@@ -377,7 +377,7 @@ Regression tests added in `DynamicJsonElementTests` (`TryConvert_ObjectTarget_On
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/ScriptParameters.cs`, `src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs`, `src/ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs`, `src/ScadaLink.Commons/Types/StaleTagMonitor.cs`, `src/ScadaLink.Commons/Types/ScriptArgs.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/ScriptParameters.cs`, `src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/OpcUaEndpointConfigSerializer.cs`, `src/ZB.MOM.WW.ScadaBridge.Commons/Validators/OpcUaEndpointConfigValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.Commons/Types/StaleTagMonitor.cs`, `src/ZB.MOM.WW.ScadaBridge.Commons/Types/ScriptArgs.cs` |
**Description**
@@ -423,7 +423,7 @@ carve-out makes these types compliant by design.
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Messages/Management/InstanceCommands.cs:10` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs:10` |
**Description**
@@ -450,12 +450,12 @@ as `Item1`/`Item2`, unfriendly to REQ-COM-5a additive evolution. Introduced a na
`ConnectionBinding(string AttributeName, int DataConnectionId)` in
`Messages/Management/InstanceCommands.cs` (alongside the command, matching the existing
record-per-file convention) and changed `SetConnectionBindingsCommand.Bindings` to
`IReadOnlyList<ConnectionBinding>`. All consumers were updated in lock-step: `ScadaLink.CLI`
`IReadOnlyList<ConnectionBinding>`. All consumers were updated in lock-step: `ZB.MOM.WW.ScadaBridge.CLI`
(`InstanceCommands.TryParseBindings` now builds a `List<ConnectionBinding>`),
`ScadaLink.TemplateEngine` (`InstanceService.SetConnectionBindingsAsync` parameter),
`ScadaLink.ManagementService` (`ManagementActor` forwards `cmd.Bindings` unchanged — the
`ZB.MOM.WW.ScadaBridge.TemplateEngine` (`InstanceService.SetConnectionBindingsAsync` parameter),
`ZB.MOM.WW.ScadaBridge.ManagementService` (`ManagementActor` forwards `cmd.Bindings` unchanged — the
new element type flows through), and one consumer the finding did not list,
`ScadaLink.CentralUI` (`InstanceConfigure.razor`'s `SaveBindings` built a `List<(string,int)>`).
`ZB.MOM.WW.ScadaBridge.CentralUI` (`InstanceConfigure.razor`'s `SaveBindings` built a `List<(string,int)>`).
A repo-wide `src/` scan confirms no other references remain. Regression tests added in
`ConnectionBindingSerializationTests` (`ConnectionBinding_SerializesWithNamedProperties`
asserts named `AttributeName`/`DataConnectionId` JSON properties and the absence of
@@ -463,7 +463,7 @@ asserts named `AttributeName`/`DataConnectionId` JSON properties and the absence
JSON round-trip); existing parse/forward tests (`ParseBindings_ValidJson_ReturnsPairs`,
`SetConnectionBindings_BulkAssignment_Success`) were updated to the new type. Affected
suites are green (Commons 226, CLI 77, ManagementService 55, TemplateEngine 287) and
`dotnet build ScadaLink.slnx` is clean.
`dotnet build ZB.MOM.WW.ScadaBridge.slnx` is clean.
### Commons-009 — `Component-Commons.md` is stale relative to the actual file set
@@ -521,11 +521,11 @@ regression test.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.Commons.Tests/` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/` |
**Description**
`ScadaLink.Commons.Tests` covers `Result`, `RetryPolicy`, `ScriptParameters`,
`ZB.MOM.WW.ScadaBridge.Commons.Tests` covers `Result`, `RetryPolicy`, `ScriptParameters`,
`StaleTagMonitor`, the OPC UA validator, enums, message conventions, compatibility, and
entity conventions. It does not cover several types that contain exactly the kind of
edge-case logic that warrants tests:
@@ -567,7 +567,7 @@ tests (up from 196).
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/Result.cs:15-20`, `:30-32`, `:36` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Result.cs:15-20`, `:30-32`, `:36` |
**Description**
@@ -600,7 +600,7 @@ interpolated string, so no consumer is broken. Regression tests added in `Result
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/ValueFormatter.cs:20-27` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/ValueFormatter.cs:20-27` |
**Description**
@@ -639,7 +639,7 @@ each pinned under `de-DE`).
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/DynamicJsonElement.cs:40-54` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/DynamicJsonElement.cs:40-54` |
**Description**
@@ -682,7 +682,7 @@ tests added in `DynamicJsonElementTests` (`IndexAccess_WithLongIndex_Works`,
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs:107-131` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/OpcUaEndpointConfigSerializer.cs:107-131` |
**Description**
@@ -733,7 +733,7 @@ describe the corrupt-typed-row branch. Regression tests added in
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/Transport/EncryptionMetadata.cs:3-8` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/EncryptionMetadata.cs:3-8` |
**Resolution (2026-05-28):** Converted `EncryptionMetadata` to a non-positional `record` with an explicit constructor that enforces invariants at the type boundary: `Algorithm` must equal `"AES-256-GCM"`, `Kdf` must equal `"PBKDF2-SHA256"`, `Iterations` must lie in `[MinPbkdf2Iterations=100_000, MaxPbkdf2Iterations=10_000_000]`, and `SaltB64`/`IvB64` must be non-null (empty permitted for the BundleSerializer.Pack seed pattern). Invalid values throw `ArgumentException` (or `ArgumentNullException`) naming the offending field. Added `EncryptionMetadataTests` covering valid construction, unknown algorithm/KDF (including case sensitivity), out-of-range iteration counts (including both boundaries), and null salt/IV. Updated `BundleSecretEncryptorTests` / `BundleSerializerTests` to use `MinPbkdf2Iterations` instead of the prior 10_000 placeholder.
@@ -785,7 +785,7 @@ accepted values on the record.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Types/Transport/BundleSession.cs:13-16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSession.cs:13-16` |
**Resolution (2026-05-28):** Added `public const int MaxUnlockAttempts = 3;` to `BundleSession` with an XML doc cross-referencing the authoritative `TransportOptions.MaxUnlockAttemptsPerSession`. The `Locked` getter now reads `FailedUnlockAttempts >= MaxUnlockAttempts` instead of comparing against the literal `3`, and the property's XML doc names the constant. No call-site update required — the existing Transport-component `TransportOptions.MaxUnlockAttemptsPerSession` (also `3`) remains the operator-facing dial; this constant is the shim's own threshold, now searchable for a security review.
@@ -883,17 +883,17 @@ needed again now.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Interfaces/IOperationTrackingStore.cs`, `src/ScadaLink.Commons/Interfaces/IPartitionMaintenance.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/IOperationTrackingStore.cs`, `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/IPartitionMaintenance.cs` |
**Resolution (2026-05-28):** Moved both files into `src/ScadaLink.Commons/Interfaces/Services/`, matching the REQ-COM-5b sub-folder convention alongside the other service interfaces (`ISiteAuditQueue`, `INodeIdentityProvider`, `ICachedCallLifecycleObserver`, etc.). The 9 consumer files across `ScadaLink.SiteRuntime`, `ScadaLink.AuditLog`, `ScadaLink.ConfigurationDatabase`, and `ScadaLink.Host` exceed the in-instructions 8-file STOP threshold for namespace rewrites, so the namespace was deliberately kept as `ScadaLink.Commons.Interfaces` (not `.Services`) — no consumer change required, build remains green. A comment in each moved file records the rationale and notes that adopting the canonical `.Services` namespace can be picked up alongside any future Commons-wide namespace tidy-up.
**Resolution (2026-05-28):** Moved both files into `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/`, matching the REQ-COM-5b sub-folder convention alongside the other service interfaces (`ISiteAuditQueue`, `INodeIdentityProvider`, `ICachedCallLifecycleObserver`, etc.). The 9 consumer files across `ZB.MOM.WW.ScadaBridge.SiteRuntime`, `ZB.MOM.WW.ScadaBridge.AuditLog`, `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`, and `ZB.MOM.WW.ScadaBridge.Host` exceed the in-instructions 8-file STOP threshold for namespace rewrites, so the namespace was deliberately kept as `ZB.MOM.WW.ScadaBridge.Commons.Interfaces` (not `.Services`) — no consumer change required, build remains green. A comment in each moved file records the rationale and notes that adopting the canonical `.Services` namespace can be picked up alongside any future Commons-wide namespace tidy-up.
**Description**
REQ-COM-5b documents the `Interfaces/` folder as having exactly three sub-folders:
`Protocol/` (REQ-COM-2), `Repositories/` (REQ-COM-4), and `Services/` (REQ-COM-4a). Two
new interfaces — `IOperationTrackingStore` and `IPartitionMaintenance` — are filed at
the root of `Interfaces/` (namespace `ScadaLink.Commons.Interfaces`) rather than under
`Interfaces/Services/` (namespace `ScadaLink.Commons.Interfaces.Services`). They are
the root of `Interfaces/` (namespace `ZB.MOM.WW.ScadaBridge.Commons.Interfaces`) rather than under
`Interfaces/Services/` (namespace `ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services`). They are
straightforward cross-cutting service interfaces consumed by the Audit Log component (a
site-local SQLite tracking store; a central partition-maintenance hosted-service helper)
and conceptually belong alongside `ISiteAuditQueue`, `ICachedCallLifecycleObserver`, etc.
@@ -904,8 +904,8 @@ other recently-added service interface uses `Interfaces.Services`.
**Recommendation**
Move both files into `Interfaces/Services/` and adjust the namespace to
`ScadaLink.Commons.Interfaces.Services`. Update consumers in `ScadaLink.AuditLog`,
`ScadaLink.SiteRuntime`, and `ScadaLink.ConfigurationDatabase`. Add them to the
`ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services`. Update consumers in `ZB.MOM.WW.ScadaBridge.AuditLog`,
`ZB.MOM.WW.ScadaBridge.SiteRuntime`, and `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`. Add them to the
REQ-COM-4a list (see Commons-017).
### Commons-019 — New `*Utc`-suffixed `DateTime` columns on `AuditEvent` / `SiteCall` are not enforced as UTC; inconsistent with `Notification`'s `DateTimeOffset`
@@ -915,9 +915,9 @@ REQ-COM-4a list (see Commons-017).
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs:15-18`, `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs:59-68`, `tests/ScadaLink.Commons.Tests/Entities/EntityConventionTests.cs:49-69` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditEvent.cs:15-18`, `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/SiteCall.cs:59-68`, `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/EntityConventionTests.cs:49-69` |
**Resolution (2026-05-28):** Kept the `DateTime` type on `AuditEvent` (a `DateTimeOffset` migration is a data-shape change beyond this finding's scope) and instead enforced the UTC invariant at the assignment boundary. `AuditEvent.OccurredAtUtc` / `IngestedAtUtc` now have init-setters that call `DateTime.SpecifyKind(value, DateTimeKind.Utc)` via private backing fields, so any value supplied with `Kind=Unspecified` (`DateTime` literal, JSON deserialise, EF hydrate that bypassed the converter) is re-tagged as UTC on assignment. The record-level XML doc gained a remarks block stating the invariant and contrasting with `Notification`'s `DateTimeOffset` shape. Sibling `ConfigurationDatabase-018` adds the matching EF value converter so the read path also enforces `Kind=Utc`; the two changes travelled together. Regression coverage in `tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs::Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc`. The `SiteCall` and `EntityConventionTests` sub-points named in the location list are out of scope for this close (they fall under sibling code-review tasks).
**Resolution (2026-05-28):** Kept the `DateTime` type on `AuditEvent` (a `DateTimeOffset` migration is a data-shape change beyond this finding's scope) and instead enforced the UTC invariant at the assignment boundary. `AuditEvent.OccurredAtUtc` / `IngestedAtUtc` now have init-setters that call `DateTime.SpecifyKind(value, DateTimeKind.Utc)` via private backing fields, so any value supplied with `Kind=Unspecified` (`DateTime` literal, JSON deserialise, EF hydrate that bypassed the converter) is re-tagged as UTC on assignment. The record-level XML doc gained a remarks block stating the invariant and contrasting with `Notification`'s `DateTimeOffset` shape. Sibling `ConfigurationDatabase-018` adds the matching EF value converter so the read path also enforces `Kind=Utc`; the two changes travelled together. Regression coverage in `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs::Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc`. The `SiteCall` and `EntityConventionTests` sub-points named in the location list are out of scope for this close (they fall under sibling code-review tasks).
**Description**
@@ -967,16 +967,16 @@ Option 2 is the smaller change and is consistent with how `AuditLog` rows are st
SQL Server (`datetime2`, no offset). Either way the inconsistency with `Notification`
should be documented in REQ-COM-1 as a deliberate choice.
### Commons-020 — Transport types and new Audit-message types have no unit tests in `ScadaLink.Commons.Tests`
### Commons-020 — Transport types and new Audit-message types have no unit tests in `ZB.MOM.WW.ScadaBridge.Commons.Tests`
| | |
|--|--|
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.Commons.Tests/` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/` |
**Resolution (2026-05-28):** Added `tests/ScadaLink.Commons.Tests/Types/Transport/TransportRecordsTests.cs` (14 tests) covering ctor and System.Text.Json round-trip for `BundleManifest`, `ExportSelection`, `ImportPreview` + `ImportPreviewItem`, `ImportResolution` (all four `ResolutionAction`s), and `ImportResult`, plus a record-equality sanity check that catches a positional/tuple slip. `EncryptionMetadata` (Commons-015), `AuditEvent` (init-setter / `SourceNode`), `CachedCallTelemetry`, and `AuditTelemetryEnvelope` were verified already covered by their existing focused test files; no new tests were added for those to avoid duplication.
**Resolution (2026-05-28):** Added `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/TransportRecordsTests.cs` (14 tests) covering ctor and System.Text.Json round-trip for `BundleManifest`, `ExportSelection`, `ImportPreview` + `ImportPreviewItem`, `ImportResolution` (all four `ResolutionAction`s), and `ImportResult`, plus a record-equality sanity check that catches a positional/tuple slip. `EncryptionMetadata` (Commons-015), `AuditEvent` (init-setter / `SourceNode`), `CachedCallTelemetry`, and `AuditTelemetryEnvelope` were verified already covered by their existing focused test files; no new tests were added for those to avoid duplication.
**Description**
@@ -984,8 +984,8 @@ The Transport (#24) work adds nine records under `Types/Transport/` (`BundleMani
`EncryptionMetadata`, `BundleSession`, `BundleSummary`, `ExportSelection`,
`ImportPreview` + `ImportPreviewItem`, `ImportResolution`, `ImportResult`,
`ManifestContentEntry`) and four interfaces under `Interfaces/Transport/`. None of them
have a focused test file in `tests/ScadaLink.Commons.Tests/` — coverage is entirely
inside `tests/ScadaLink.Transport.IntegrationTests/`, which exercises the
have a focused test file in `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/` — coverage is entirely
inside `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/`, which exercises the
end-to-end exporter/importer flow but does not pin the Commons-level wire contracts.
Similarly, the new `Messages/Audit/` folder (`IngestAuditEventsCommand`/`Reply`,
@@ -1005,11 +1005,11 @@ regression.
**Recommendation**
Add focused tests in `tests/ScadaLink.Commons.Tests/Types/Transport/` (round-trip
Add focused tests in `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Transport/` (round-trip
serialization for each Transport record, named JSON property assertions for
`EncryptionMetadata` / `BundleManifest`, the `BundleSession.Locked` threshold —
see Commons-016, the `ConflictKind`/`ResolutionAction` enum coverage), and in
`tests/ScadaLink.Commons.Tests/Messages/Audit/` (round-trip + named-property assertions
`tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/Audit/` (round-trip + named-property assertions
for the seven new message files). Prioritise the contracts that cross the site→central
boundary (`AuditTelemetryEnvelope`, `PullAuditEventsRequest`/`Response`,
`IngestCachedTelemetryCommand`).
@@ -1021,7 +1021,7 @@ boundary (`AuditTelemetryEnvelope`, `PullAuditEventsRequest`/`Response`,
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs:91-104` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/IExternalSystemClient.cs:91-104` |
**Resolution (2026-05-28):** Replaced the two mutable backing fields (`_response`/`_responseParsed`) with a single `private readonly Lazy<dynamic?> _response` initialised in the field initializer — `LazyThreadSafetyMode.ExecutionAndPublication` (the default) guarantees the parse runs at most once and every concurrent reader observes the same published `DynamicJsonElement`. `Response` is now a one-line `_response.Value` expression-bodied property. Regression test `ExternalCallResultTests.Response_ConcurrentReads_ReturnSameInstance` fires 64 concurrent readers through a `Barrier` and asserts `Assert.Same` across all observed values.
@@ -1074,7 +1074,7 @@ behavior.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Interfaces/Transport/IAuditCorrelationContext.cs:11`, `src/ScadaLink.Commons/Types/Transport/ImportPreview.cs:11`, `src/ScadaLink.Commons/Entities/Notifications/Notification.cs:33` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IAuditCorrelationContext.cs:11`, `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/ImportPreview.cs:11`, `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/Notification.cs:33` |
**Resolution (2026-05-28):** Replaced the unresolvable `<see cref="BundleImporter.ApplyAsync"/>` in `IAuditCorrelationContext` with a plain-text `BundleImporter.ApplyAsync` reference (qualified inline as "in the Transport component") so the XML doc no longer emits a CS1574 warning from Commons, which cannot see the implementation type. The `ImportPreviewItem.FieldDiffJson` / `Notification.ResolvedTargets` JSON-shape sub-point is tracked separately and not in scope for this close — the XML doc on `IAuditCorrelationContext` does not name those columns.
@@ -1084,7 +1084,7 @@ Two related XML-doc weaknesses, both around the new Transport / Audit surface:
1. `IAuditCorrelationContext`'s remarks say
`<see cref="BundleImporter.ApplyAsync"/>`. `BundleImporter` is the concrete
implementation in `ScadaLink.Transport.Import`, which Commons does not (and must
implementation in `ZB.MOM.WW.ScadaBridge.Transport.Import`, which Commons does not (and must
not) reference. The cref is unresolvable from Commons and will surface as a
build-time XML doc warning. The correct reference is the interface method
`IBundleImporter.ApplyAsync`.
@@ -1120,7 +1120,7 @@ Two related XML-doc weaknesses, both around the new Transport / Audit surface:
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs:53-66`, `:110-123`, `src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs:26-39`, `:104-123`, `src/ScadaLink.Commons/Types/SiteCallOperational.cs:42-54`, `src/ScadaLink.Commons/Types/TrackingStatusSnapshot.cs:33-46` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Audit/SiteCallQueries.cs:53-66`, `:110-123`, `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Notification/NotificationOutboxQueries.cs:26-39`, `:104-123`, `src/ZB.MOM.WW.ScadaBridge.Commons/Types/SiteCallOperational.cs:42-54`, `src/ZB.MOM.WW.ScadaBridge.Commons/Types/TrackingStatusSnapshot.cs:33-46` |
**Resolution (2026-05-28):** Read all six locations and confirmed the dominant pattern is "trailing-optional with `= null` default" (`SiteCallSummary`, `SiteCallDetail`, `NotificationSummary`, `NotificationDetail`, `NotificationOutboxQueryRequest.SourceNodeFilter`, `SiteCallQueryRequest.SourceNodeFilter` all already use this form). The single odd-one-out was `TrackingStatusSnapshot.SourceNode`, declared as `string? SourceNode` with no default — added the `= null` default to unify it with the rest. Verified both existing callers (`OperationTrackingStore.cs` and `TrackingApiTests.cs`) use named arguments, so the change is purely additive. `SiteCallOperational.SourceNode` sits in the middle of its positional parameter list rather than the trailing slot — that's a separate positional-record concern outside the "trailing-optional" pattern the finding called out, and moving it would touch many telemetry/proto consumers, so it was deliberately not touched here.
+26 -26
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.Communication` |
| Module | `src/ZB.MOM.WW.ScadaBridge.Communication` |
| Design doc | `docs/requirements/Component-Communication.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -107,7 +107,7 @@ Low.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/DebugStreamService.cs:130-143` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/DebugStreamService.cs:130-143` |
**Re-triaged 2026-05-16:** originally filed Critical, claiming an orphaned bridge actor
and a multi-minute site-side resource leak on every snapshot timeout. On verification
@@ -157,7 +157,7 @@ references `Communication-001`.
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:170`, `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:143` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs:170`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs:143` |
**Description**
@@ -200,7 +200,7 @@ fails against the pre-fix code and passes after.
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs:77`, `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs:106` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs:77`, `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs:106` |
**Description**
@@ -246,7 +246,7 @@ fail against the pre-fix logic and pass after.
| Severity | Medium |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:42`, `src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs:22` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:42`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs:22` |
**Description**
@@ -289,7 +289,7 @@ against the pre-fix code (decider yields `Restart`) and pass after.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClient.cs:25`, `src/ScadaLink.Communication/CommunicationOptions.cs:36` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs:25`, `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs:36` |
**Description**
@@ -320,7 +320,7 @@ option and update the design doc.
Resolved 2026-05-16 (commit pending). Root cause confirmed: `SiteStreamGrpcClient`
hard-coded the keepalive values, `GrpcMaxStreamLifetime` was referenced nowhere, and
`GrpcMaxConcurrentStreams` was never bound to the server. Fix (scoped to
`src/ScadaLink.Communication`): `SiteStreamGrpcClient` gained a constructor taking
`src/ZB.MOM.WW.ScadaBridge.Communication`): `SiteStreamGrpcClient` gained a constructor taking
`CommunicationOptions` and now applies `GrpcKeepAlivePingDelay`/`GrpcKeepAlivePingTimeout`
to its `SocketsHttpHandler`; `SiteStreamGrpcClientFactory` gained an
`IOptions<CommunicationOptions>` DI constructor and flows the options into every client
@@ -341,7 +341,7 @@ exercise the wiring (they require the new members to even compile).
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:204` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:204` |
**Description**
@@ -380,7 +380,7 @@ against the pre-fix code and passes after.
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClientFactory.cs:53` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClientFactory.cs:53` |
**Description**
@@ -419,7 +419,7 @@ fails against the pre-fix code (clients disposed via `DisposeAsync`) and passes
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:71`, `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:174` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs:71`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs:174` |
**Description**
@@ -464,7 +464,7 @@ per-event reset (`Grpc_Error_Resets_RetryCount_On_Successful_Event`) was replace
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:53`, `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:240` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:53`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:240` |
**Description**
@@ -503,7 +503,7 @@ registered) and passes after.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:10` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs:10` |
**Description**
@@ -535,7 +535,7 @@ not derive from an Akka.Persistence base class; its state does not survive a res
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.Communication.Tests/` (module-wide) |
| Location | `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/` (module-wide) |
**Description**
@@ -575,7 +575,7 @@ passes after):
- Malformed `NodeAAddress` aborting `HandleSiteAddressCacheLoaded` (Communication-009) —
`CentralCommunicationActorTests.MalformedSiteAddress_DoesNotAbortRefresh_OtherSitesStillRegistered`
(added with this finding's resolution).
The full module suite (`dotnet test tests/ScadaLink.Communication.Tests`) is green at
The full module suite (`dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests`) is green at
111 passing tests.
### Communication-012 — gRPC client factory ignores the endpoint on a cache hit, breaking NodeA→NodeB stream failover
@@ -585,7 +585,7 @@ The full module suite (`dotnet test tests/ScadaLink.Communication.Tests`) is gre
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClientFactory.cs:39`, `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs:166` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClientFactory.cs:39`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/DebugStreamBridgeActor.cs:166` |
**Description**
@@ -642,7 +642,7 @@ factory and pass after.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcClientFactory.cs:58` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClientFactory.cs:58` |
**Description**
@@ -691,7 +691,7 @@ after.
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs:124` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs:124` |
**Description**
@@ -734,7 +734,7 @@ accepted.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.Communication.Tests/Grpc/DebugStreamBridgeActorTests.cs:401`, `tests/ScadaLink.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryTests.cs` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/DebugStreamBridgeActorTests.cs:401`, `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcClientFactoryTests.cs` |
**Description**
@@ -775,14 +775,14 @@ than being masked by an endpoint-agnostic mock.
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:169`, `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:338-375` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:169`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:338-375` |
**Resolution** — deleted the dead code path in favour of the keepalive-based
detection that is the actual production behaviour: removed the
`Receive<ConnectionStateChanged>` handler, the `HandleConnectionStateChanged`
method, the `_debugSubscriptions` / `_inProgressDeployments` tracking dicts
+ the `TrackMessageForCleanup` helper that fed them, and the dead message
record `src/ScadaLink.Commons/Messages/Communication/ConnectionStateChanged.cs`.
record `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Communication/ConnectionStateChanged.cs`.
The two dead tests (`ConnectionLost_DebugStreamsKilled` in
CentralCommunicationActorTests, `RoundTrip_ConnectionStateChanged_Succeeds`
in CompatibilityTests) were removed alongside. The design doc
@@ -858,7 +858,7 @@ Either way, replace `CentralCommunicationActorTests.ConnectionLost_DebugStreamsK
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:73`, `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:501`, `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:357-367` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:73`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:501`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:357-367` |
**Resolution (2026-05-28):** Closed by Comm-016 — field removed in commit ac96b83.
The `_inProgressDeployments` dictionary, the `TrackMessageForCleanup` helper,
@@ -917,7 +917,7 @@ caller, so the reply skips the coordinator.)
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs:376-465` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs:376-465` |
**Description**
@@ -966,7 +966,7 @@ Communication.Tests).
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:397-431` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:397-431` |
**Description**
@@ -1006,7 +1006,7 @@ the finding.
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:567` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:567` |
**Resolution (2026-05-28):** `SiteAddressCacheLoaded`'s `SiteContacts` payload is now typed as `IReadOnlyDictionary<string, IReadOnlyList<string>>`, enforcing the Akka.NET message-immutability convention at the type level rather than relying on producer discipline. The producer (`LoadSiteAddressesFromDb`) builds the working buckets as before and wraps each inner `List<string>` with `AsReadOnly()` before constructing the message — the freeze is local to the single refresh tick and the cost is negligible. The consumer (`HandleSiteAddressCacheLoaded`) only ever read via `Keys`, foreach-deconstruct, `Select`, `Count` and `ToImmutableHashSet`, all of which are supported by the new read-only types, so no consumer changes were needed. The existing `MalformedSiteAddress_DoesNotAbortRefresh_OtherSitesStillRegistered` and `ClusterClientRouting_RoutesToConfiguredSite` regression tests exercise the producer→consumer flow and continue to pass under the read-only types.
@@ -1037,7 +1037,7 @@ once per refresh tick.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs:188-200` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs:188-200` |
**Description**
@@ -1086,7 +1086,7 @@ asserts `ActiveStreamCount == 0` and that `RemoveSubscriber` was NOT called
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:67`, `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:493` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:67`, `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/CentralCommunicationActor.cs:493` |
**Description**
+47 -47
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.ConfigurationDatabase` |
| Module | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` |
| Design doc | `docs/requirements/Component-ConfigurationDatabase.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -13,7 +13,7 @@
## Summary
The ConfigurationDatabase module is a focused, conventional EF Core data-access layer:
a single `ScadaLinkDbContext`, Fluent API entity configurations, eight repository
a single `ScadaBridgeDbContext`, Fluent API entity configurations, eight repository
implementations of Commons-defined interfaces, an `IAuditService` implementation, an
`IInstanceLocator`, environment-aware migration handling, and design-time tooling
support. Overall structure adheres well to the design doc and the CLAUDE.md "Code
@@ -151,7 +151,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:30-41` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:30-41` |
**Description**
@@ -185,7 +185,7 @@ none consume derived/sub-templates; they all need the template's *member* collec
(Attributes/Alarms/Scripts/Compositions), which `GetTemplateByIdAsync` already
eager-loads. The `Template` entity has no child-templates navigation collection, and
adding one (plus changing the interface signature) would require editing
`ScadaLink.Commons`, which is outside this module's scope.
`ZB.MOM.WW.ScadaBridge.Commons`, which is outside this module's scope.
Fix applied the recommendation's secondary option: removed the dead query so the
method no longer misleads or wastes a round-trip, and added an XML doc comment
@@ -204,12 +204,12 @@ template-aggregate contract the callers depend on.
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/DesignTimeDbContextFactory.cs:21-22` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/DesignTimeDbContextFactory.cs:21-22` |
**Description**
`DesignTimeDbContextFactory` falls back to a literal connection string
`"Server=localhost,1433;Database=ScadaLink_Config;User Id=sa;Password=YourPassword;TrustServerCertificate=True"`
`"Server=localhost,1433;Database=ScadaBridge_Config;User Id=sa;Password=YourPassword;TrustServerCertificate=True"`
when no configured connection string is found. Embedding a credential literal (even a
placeholder) in source code is a poor pattern: it is committed to version control,
encourages copy-paste of `sa`/`TrustServerCertificate=True` into real environments, and
@@ -220,7 +220,7 @@ silently pointing tooling at an unintended database.
Remove the hardcoded fallback. If no connection string is resolved from configuration
or environment, throw a clear `InvalidOperationException` instructing the developer to
set `ScadaLink:Database:ConfigurationDb` (or an environment variable). At minimum, read
set `ScadaBridge:Database:ConfigurationDb` (or an environment variable). At minimum, read
the design-time connection string from an environment variable rather than a literal,
and never use `sa`.
@@ -233,7 +233,7 @@ resolves the connection string from the Host's appsettings files or, when those
present, from the `SCADALINK_DESIGNTIME_CONNECTIONSTRING` environment variable, and
throws a clear `InvalidOperationException` (naming both the config key and the env var)
when neither yields a value. Also hardened `SetBasePath` to be applied only when the
`ScadaLink.Host` directory exists, so the factory degrades cleanly instead of throwing
`ZB.MOM.WW.ScadaBridge.Host` directory exists, so the factory degrades cleanly instead of throwing
`DirectoryNotFoundException` when run from a context without a sibling Host folder.
Regression tests added in `DesignTimeDbContextFactoryTests.cs`:
`CreateDbContext_NoConnectionStringConfigured_ThrowsClearException`,
@@ -247,13 +247,13 @@ Regression tests added in `DesignTimeDbContextFactoryTests.cs`:
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs:44-49` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs:44-49` |
**Description**
The parameterless `AddConfigurationDatabase()` overload is a deliberate no-op "retained
for backward compatibility during migration." If a central node is wired up with this
overload by mistake, no `ScadaLinkDbContext`, repositories, `IAuditService`, or
overload by mistake, no `ScadaBridgeDbContext`, repositories, `IAuditService`, or
`IInstanceLocator` are registered. The failure does not surface at startup; it surfaces
much later as opaque DI resolution exceptions the first time any consumer requests a
repository — far from the actual misconfiguration. The XML comment also refers to
@@ -291,7 +291,7 @@ New regression tests added in `ServiceCollectionExtensionsTests.cs`:
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs:56-57`, `src/ScadaLink.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs:25-26,75-77` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/NotificationConfiguration.cs:56-57`, `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs:25-26,75-77` |
**Description**
@@ -325,7 +325,7 @@ backed by ASP.NET Data Protection, which the module already uses
(`IDataProtectionKeyContext`, `AddDataProtection().PersistKeysToDbContext`). Added
`EncryptedStringConverter` (purpose-scoped `IDataProtector`; `Protect` on write,
`Unprotect` on read; null-safe; surfaces a clear message on a `CryptographicException`).
`ScadaLinkDbContext` gained an `(options, IDataProtectionProvider)` constructor and
`ScadaBridgeDbContext` gained an `(options, IDataProtectionProvider)` constructor and
applies the converter to the three secret columns in `OnModelCreating`; the DI
registration in `ServiceCollectionExtensions` now constructs the context with the
registered provider. The secret columns were widened to `HasMaxLength(8000)` (EF maps
@@ -338,7 +338,7 @@ columns plus a null round-trip.
The encryption scheme itself is fully in-module; the only remaining cross-cutting item
is a documentation gap — the design doc does not yet state encryption-at-rest for these
fields. That doc update is outside this module's editable scope (constraint: edit only
`src/ScadaLink.ConfigurationDatabase`, the tests, and this file) and is surfaced here
`src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`, the tests, and this file) and is surfaced here
for a follow-up to `docs/requirements/Component-ConfigurationDatabase.md`. The audit
secret-leak concern is mitigated separately by CD-007's serializer hardening; whether
callers should additionally redact secret-bearing entities before passing them to
@@ -352,7 +352,7 @@ follow-up. The code fix in this module is complete.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditConfiguration.cs:11` (entity `src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditConfiguration.cs:11` (entity `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditLogEntry.cs`) |
**Description**
@@ -374,8 +374,8 @@ Resolve the discrepancy in one direction.
Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the
`AuditLogEntry` entity declares `int Id`, while the design doc's Audit Entry Schema
table said `Long / GUID`. The entity lives in `ScadaLink.Commons`
(`src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`), which is outside this
table said `Long / GUID`. The entity lives in `ZB.MOM.WW.ScadaBridge.Commons`
(`src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Audit/AuditLogEntry.cs`), which is outside this
module's editable scope, so the discrepancy was resolved by aligning the design doc to
the code — the recommendation's second option. The schema table now records `Id` as
`int (identity)` with an explicit justification: a 32-bit identity matches the key type
@@ -394,7 +394,7 @@ already exercise the `int` key end to end.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs:24-25` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SiteConfiguration.cs:24-25` |
**Description**
@@ -434,7 +434,7 @@ the bound of the `NodeAAddress`/`NodeBAddress` siblings).
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs:28-30` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Services/AuditService.cs:28-30` |
**Description**
@@ -483,7 +483,7 @@ added in `AuditServiceTests.cs`:
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:46-58` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/InboundApiRepository.cs:46-58` |
**Description**
@@ -532,7 +532,7 @@ capturing `ILogger` to assert the warning is emitted only on malformed input).
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:43-51,53-61`, `src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs:45-55` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:43-51,53-61`, `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/CentralUiRepository.cs:45-55` |
**Description**
@@ -580,7 +580,7 @@ not a 24-row cartesian product) and
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`, `Repositories/DeploymentManagerRepository.cs`, `Repositories/ExternalSystemRepository.cs`, `Repositories/InboundApiRepository.cs`, `Repositories/NotificationRepository.cs`, `Repositories/SiteRepository.cs`, `Services/InstanceLocator.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs`, `Repositories/DeploymentManagerRepository.cs`, `Repositories/ExternalSystemRepository.cs`, `Repositories/InboundApiRepository.cs`, `Repositories/NotificationRepository.cs`, `Repositories/SiteRepository.cs`, `Services/InstanceLocator.cs` |
**Description**
@@ -629,13 +629,13 @@ as the CD-011 regression guard. The full module suite is green.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs:11-14`, `Repositories/InboundApiRepository.cs:11-14`, `Repositories/NotificationRepository.cs:11-14`, `Services/InstanceLocator.cs:13-16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/ExternalSystemRepository.cs:11-14`, `Repositories/InboundApiRepository.cs:11-14`, `Repositories/NotificationRepository.cs:11-14`, `Services/InstanceLocator.cs:13-16` |
**Description**
`SecurityRepository`, `CentralUiRepository`, `TemplateEngineRepository`,
`DeploymentManagerRepository`, `SiteRepository`, and `AuditService` all guard their
injected `ScadaLinkDbContext` with `?? throw new ArgumentNullException(...)`.
injected `ScadaBridgeDbContext` with `?? throw new ArgumentNullException(...)`.
`ExternalSystemRepository`, `InboundApiRepository`, `NotificationRepository`, and
`InstanceLocator` assign the constructor argument directly with no guard. This is a
minor consistency/maintainability issue: although the DI container will not normally
@@ -651,7 +651,7 @@ inconsistent constructors so all data-access types behave uniformly.
Resolved 2026-05-16 (commit pending). Root cause confirmed against source:
`ExternalSystemRepository`, `InboundApiRepository`, `NotificationRepository`, and
`InstanceLocator` assigned the injected `ScadaLinkDbContext` directly with no null
`InstanceLocator` assigned the injected `ScadaBridgeDbContext` directly with no null
guard, diverging from `SecurityRepository`/`CentralUiRepository`/`TemplateEngineRepository`/
`DeploymentManagerRepository`/`SiteRepository`/`AuditService`. Applied the recommendation:
all four constructors now use `context ?? throw new ArgumentNullException(nameof(context))`
@@ -668,7 +668,7 @@ Regression: `Constructor_NullContext_Throws` tests were added for all four affec
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/InboundApiConfiguration.cs:17-19` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InboundApiConfiguration.cs:17-19` |
**Description**
@@ -720,10 +720,10 @@ Design implemented — **deterministic keyed hash** (the recommendation's first
is already a high-entropy random token, and a random salt would break the
deterministic by-value lookup the authentication path relies on. The pepper instead
binds every hash to the deployment. Implemented as `IApiKeyHasher` / `ApiKeyHasher`
in `ScadaLink.Commons` (`Types/InboundApi/ApiKeyHasher.cs`); the constructor
in `ZB.MOM.WW.ScadaBridge.Commons` (`Types/InboundApi/ApiKeyHasher.cs`); the constructor
rejects a missing or weak (`< 16`-char) pepper with `ArgumentException` — fail-fast.
- **Where the pepper lives.** `InboundApiOptions.ApiKeyPepper`, a component-owned
Options class already bound from the `ScadaLink:InboundApi` configuration section
Options class already bound from the `ScadaBridge:InboundApi` configuration section
(Options pattern); it is never hard-coded. `AddInboundAPI` registers `IApiKeyHasher`
via a factory that reads the bound options, so a missing/weak pepper fails the
deployment fast rather than degrading silently. (Operators must supply the pepper
@@ -772,14 +772,14 @@ doc, and update the Central UI API-keys page, which previously displayed a maske
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs:107-124` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs:107-124` |
**Description**
`ApplySecretColumnEncryption` resolves the Data Protection provider as
`_dataProtectionProvider ?? new EphemeralDataProtectionProvider()`. The `??` fallback
is reached whenever the context is constructed via the single-argument
`ScadaLinkDbContext(DbContextOptions)` constructor — i.e. whenever no provider was
`ScadaBridgeDbContext(DbContextOptions)` constructor — i.e. whenever no provider was
injected. An `EphemeralDataProtectionProvider` generates a key ring that lives only in
process memory and is discarded at process exit.
@@ -788,8 +788,8 @@ it only emits schema). The risk is on a *runtime write path*. The runtime curren
gets the provider-bearing context only because `AddConfigurationDatabase` adds an
`AddScoped` factory registration that overrides EF's activator-based registration.
That override is the single thing standing between correct behaviour and silent data
corruption: any future change that resolves a `ScadaLinkDbContext` through a path the
override does not cover — an `AddPooledDbContextFactory`/`IDbContextFactory<ScadaLinkDbContext>`
corruption: any future change that resolves a `ScadaBridgeDbContext` through a path the
override does not cover — an `AddPooledDbContextFactory`/`IDbContextFactory<ScadaBridgeDbContext>`
registration, a second `AddDbContext` call, a hand-constructed context in server code —
would construct the context with the single-arg constructor, encrypt secret columns
with a throwaway key, and persist ciphertext that becomes **permanently undecryptable
@@ -806,7 +806,7 @@ single-arg constructor but mark contexts built without a real provider as
schema-only — e.g. record a flag and have the encrypting converter throw a clear
`InvalidOperationException` ("secret columns cannot be written without a configured
Data Protection key ring") on the first `Protect`, instead of producing throwaway
ciphertext. Also harden the DI wiring so a `ScadaLinkDbContext` cannot be resolved
ciphertext. Also harden the DI wiring so a `ScadaBridgeDbContext` cannot be resolved
through the EF-activator registration at all (e.g. register only the factory, or use
`AddDbContextFactory` with the explicit constructor).
@@ -847,7 +847,7 @@ fail-fast guard now closes the residual gap for any other resolution path.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs:121-123` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs:121-123` |
**Description**
@@ -892,7 +892,7 @@ columns) — asserting each column keeps an `EncryptedStringConverter`.
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs:33-45` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs:33-45` |
**Resolution** — rewrote `InsertIfNotExistsAsync` as a single raw-SQL
`IF NOT EXISTS (...) INSERT` matching the
@@ -903,7 +903,7 @@ catch on numbers 2601 (unique-index violation) and 2627
losers are logged at Debug and treated as no-ops, eliminating the
site-retry livelock. Two SQLite-targeted assertions in
`RepositoryCoverageTests` were migrated to a new MS SQL-fixture file
`tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs`,
`tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs`,
which also adds a 50-way parallel race test verifying exactly one row
lands and no exception bubbles.
@@ -947,7 +947,7 @@ throws and exactly one row lands.
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:35-39` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/InboundApiRepository.cs:35-39` |
**Resolution (2026-05-28):** Took option (a) — `InboundApiRepository` ctor now
accepts `Func<IApiKeyHasher>? hasherAccessor = null` (deferred resolution to
@@ -986,7 +986,7 @@ as "key not found".
**Recommendation**
Either (a) take `IApiKeyHasher` via constructor injection — alongside the existing
`ScadaLinkDbContext` and optional `ILogger` — and use it here so the repository
`ScadaBridgeDbContext` and optional `ILogger` — and use it here so the repository
participates in the same peppered scheme as the rest of the system; or (b) delete
the method from both the implementation and `IInboundApiRepository` (Commons) on the
grounds that the production authentication path correctly avoids it for timing
@@ -1003,7 +1003,7 @@ longer exists.
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs:83-97` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs:83-97` |
**Resolution (2026-05-28):**
`IDeploymentManagerRepository.DeleteDeploymentRecordAsync` now requires a `byte[] expectedRowVersion`
@@ -1062,9 +1062,9 @@ when the real RowVersion is supplied.
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs`, `Configurations/SiteCallEntityTypeConfiguration.cs` (mappings for `OccurredAtUtc`, `IngestedAtUtc`, `CreatedAtUtc`, `UpdatedAtUtc`, `TerminalAtUtc`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs`, `Configurations/SiteCallEntityTypeConfiguration.cs` (mappings for `OccurredAtUtc`, `IngestedAtUtc`, `CreatedAtUtc`, `UpdatedAtUtc`, `TerminalAtUtc`) |
**Resolution (2026-05-28):** Added two private static `ValueConverter<DateTime, DateTime>` / `ValueConverter<DateTime?, DateTime?>` UTC-enforcing converters to `AuditLogEntityTypeConfiguration` and applied them to `AuditEvent.OccurredAtUtc` and `AuditEvent.IngestedAtUtc` via `HasConversion(...)`. The converter re-tags `DateTimeKind.Utc` on hydrate (where SQL Server's `datetime2` provider strips the Kind flag) and on write (so a producer-supplied `Kind=Unspecified` literal still lands as UTC in the model cache). Coordinates with the sibling `Commons-019` resolution (init-setter on `AuditEvent` re-tags Kind=Utc at construction). Regression test in `tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs::Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc` inserts an Unspecified-Kind value, re-reads through a cleared change-tracker, and asserts `Kind == Utc` on both columns. The `SiteCall` mapping is out of scope for this close (sibling component task).
**Resolution (2026-05-28):** Added two private static `ValueConverter<DateTime, DateTime>` / `ValueConverter<DateTime?, DateTime?>` UTC-enforcing converters to `AuditLogEntityTypeConfiguration` and applied them to `AuditEvent.OccurredAtUtc` and `AuditEvent.IngestedAtUtc` via `HasConversion(...)`. The converter re-tags `DateTimeKind.Utc` on hydrate (where SQL Server's `datetime2` provider strips the Kind flag) and on write (so a producer-supplied `Kind=Unspecified` literal still lands as UTC in the model cache). Coordinates with the sibling `Commons-019` resolution (init-setter on `AuditEvent` re-tags Kind=Utc at construction). Regression test in `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs::Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc` inserts an Unspecified-Kind value, re-reads through a cleared change-tracker, and asserts `Kind == Utc` on both columns. The `SiteCall` mapping is out of scope for this close (sibling component task).
**Description**
@@ -1115,7 +1115,7 @@ modules.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Maintenance/AuditLogPartitionMaintenance.cs:181-199` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditLogPartitionMaintenance.cs:181-199` |
**Resolution (2026-05-28):** Took option (a) — dropped the `try/catch (SqlException)`
around the per-month SPLIT loop entirely (and the now-unused
@@ -1142,7 +1142,7 @@ by either path."
That rationale is correct only for an "already-exists" error — which the pre-check
makes impossible. Any *other* `SqlException` — a permissions failure (the
`scadalink_audit_purger` role's `ALTER ON SCHEMA::dbo` revoked or not granted), a
`scadabridge_audit_purger` role's `ALTER ON SCHEMA::dbo` revoked or not granted), a
deadlock victim, a transient connection drop, a transaction log full, an underlying
filegroup full — leaves the boundary genuinely **not** created, logs a Warning
(quiet by default in most appenders), and the next iteration tries to SPLIT the
@@ -1174,7 +1174,7 @@ aborts after the first failure with no further SPLITs.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs:378-387` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs:378-387` |
**Resolution (2026-05-28):** Wrapped the `reader.GetDateTime(0)` read with `DateTime.SpecifyKind(..., DateTimeKind.Utc)` so each returned boundary now carries `Kind=Utc`, matching the explicit defensive pattern already in `AuditLogPartitionMaintenance.GetMaxBoundaryAsync`. Added an inline comment explaining the rationale (SQL Server `datetime2` strips Kind through ADO.NET; boundary values are stored UTC). With sibling CD-018 also closed, the EF read path now enforces UTC at the column level — the raw-ADO defence here is belt-and-braces for this method, which bypasses EF entirely.
@@ -1205,7 +1205,7 @@ converter on the column) so the defence at the read site is no longer required.
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs:192-338` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs:192-338` |
**Resolution (2026-05-28):** Took the targeted (1) part of the recommendation —
the `monthBoundary` format string is now `"yyyy-MM-dd HH:mm:ss.fffffff"`
@@ -1263,7 +1263,7 @@ boundary lookup resolves to the expected partition.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs:8-14` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs:8-14` |
**Description**
@@ -1301,7 +1301,7 @@ No behaviour change.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs:99-101`, `Migrations/20260520142214_AddAuditLogTable.cs:103-107` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs:99-101`, `Migrations/20260520142214_AddAuditLogTable.cs:103-107` |
**Description**
@@ -1341,7 +1341,7 @@ index name remains `IX_AuditLog_CorrelationId`.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs`, `tests/.../RepositoryCoverageTests.cs:855-869` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditLogPartitionMaintenanceTests.cs`, `tests/.../RepositoryCoverageTests.cs:855-869` |
**Resolution (2026-05-28):** (1) Added `AuditLogPartitionMaintenanceTests.EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted` (Skippable, MS SQL fixture) — installs a `DbCommandInterceptor` that lets the 1st `ALTER PARTITION FUNCTION pf_AuditLog_Month() SPLIT RANGE` through and throws on the 2nd, asserts the exception propagates (CD-019's no-try/catch behaviour), counts exactly one successful split, and verifies the first boundary IS now persisted in `pf_AuditLog_Month` so the next tick resumes from N+1 with no holes. (2) Added `DeploymentManagerRepositoryTests.DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds` — production-shape happy path: caller holds the current RowVersion, change-tracker cleared, delete completes without throwing `DbUpdateConcurrencyException` and the row is gone (1 row affected).
+28 -28
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.DataConnectionLayer` |
| Module | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer` |
| Design doc | `docs/requirements/Component-DataConnectionLayer.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -123,7 +123,7 @@ DCL-007 fixed for `ReadBatchAsync`). New findings are numbered from
| Severity | Critical |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:473-538` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:473-538` |
**Description**
@@ -171,7 +171,7 @@ whose message references `DataConnectionLayer-001`.
| Severity | High |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionManagerActor.cs:131-141` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs:131-141` |
**Description**
@@ -204,7 +204,7 @@ intact across a transient handler exception, so the design doc's "transparent
re-subscribe" guarantee (WP-10) is preserved. The actor is a long-lived stateful
coordinator and its own Become/Stash reconnect state machine already recovers
connection-level faults — it does not need a restart. This also aligns with the
ScadaLink convention of `Resume` for coordinator actors. Regression test
ScadaBridge convention of `Resume` for coordinator actors. Regression test
`DCL002_ConnectionActorCrash_PreservesSubscriptionState` crashes the connection actor
via a synchronously-throwing write and asserts the subscription survives (health
report still shows 1 subscribed/resolved tag); it fails against the pre-fix `Restart`
@@ -218,7 +218,7 @@ code and passes after. Fixed by the commit whose message references
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs:16-17,130-131,153,163,173,183-184` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs:16-17,130-131,153,163,173,183-184` |
**Description**
@@ -264,7 +264,7 @@ code and passes after. Fixed by the commit whose message references
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:495-503,529-537` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:495-503,529-537` |
**Description**
@@ -313,7 +313,7 @@ against the pre-fix code and pass after. Fixed by the commit whose message refer
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/DataConnectionOptions.cs:15`, `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:573-590` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptions.cs:15`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:573-590` |
**Description**
@@ -357,7 +357,7 @@ unbounded code and passes after. Fixed by the commit whose message references
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:645-673,721-756` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:645-673,721-756` |
**Description**
@@ -408,7 +408,7 @@ code and 1 after.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:187-195` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:187-195` |
**Description**
@@ -451,7 +451,7 @@ after.
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:540-569` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:540-569` |
**Description**
@@ -501,7 +501,7 @@ refactor.)
| Severity | Medium — partially design-doc work outside this module's editable scope |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:189,242-297,379-449`, `docs/requirements/Component-DataConnectionLayer.md:73-85` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:189,242-297,379-449`, `docs/requirements/Component-DataConnectionLayer.md:73-85` |
**Description**
@@ -541,7 +541,7 @@ describes only the connect-failure failover path and does not mention the
unstable-disconnect trigger. **Action required (surfaced):** the DCL design doc should
be updated to document the unstable-disconnect failover path and the configurable
stability threshold; that edit was deliberately not made here because this task is
scoped to `src/ScadaLink.DataConnectionLayer`, tests, and this findings file only.
scoped to `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer`, tests, and this findings file only.
### DataConnectionLayer-010 — Tag-resolution retry can issue duplicate concurrent subscribe attempts
@@ -550,7 +550,7 @@ scoped to `src/ScadaLink.DataConnectionLayer`, tests, and this findings file onl
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:594-619,689-703` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:594-619,689-703` |
**Description**
@@ -596,7 +596,7 @@ subscribe calls); the pre-fix code dispatched on every tick (6 total).
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:486-489,278-285,416-425`, `src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:252-262` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:486-489,278-285,416-425`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:252-262` |
**Description**
@@ -644,7 +644,7 @@ subscriber against the pre-fix code and is dropped after.
| Severity | Medium — full secure default also requires a Commons + design-doc change outside this module |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Adapters/IOpcUaClient.cs:17`, `src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs:49,60-61`, `docs/requirements/Component-DataConnectionLayer.md:116` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs:17`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs:49,60-61`, `docs/requirements/Component-DataConnectionLayer.md:116` |
**Description**
@@ -660,7 +660,7 @@ untrusted certs unless an operator opts in per connection.
**Verification note**: Confirmed against source. Note the *authoritative* runtime
default does not actually live on `OpcUaConnectionOptions` — for a real connection
`OpcUaDataConnection.ConnectAsync` builds `OpcUaConnectionOptions` from
`OpcUaEndpointConfig` (in `ScadaLink.Commons`), whose `AutoAcceptUntrustedCerts`
`OpcUaEndpointConfig` (in `ZB.MOM.WW.ScadaBridge.Commons`), whose `AutoAcceptUntrustedCerts`
property also defaults to `true`. `OpcUaConnectionOptions`' own default is only the
fallback used when an `OpcUaConnectionOptions` is constructed directly.
@@ -680,13 +680,13 @@ was added as an optional constructor parameter, defaulting to `NullLogger`, so
existing callers are unaffected). Regression test
`DCL012_OpcUaConnectionOptions_AutoAcceptUntrustedCerts_DefaultsToFalse` guards the
new secure default. **Two parts remain outside this module's editable scope and are
surfaced as action required:** (a) `ScadaLink.Commons.Types.DataConnections.OpcUaEndpointConfig.AutoAcceptUntrustedCerts`
surfaced as action required:** (a) `ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections.OpcUaEndpointConfig.AutoAcceptUntrustedCerts`
still defaults to `true` — since that is the value actually used for a real connection
(see verification note above), the Commons default must also be flipped to `false`
for the system to be secure-by-default; (b) `docs/requirements/Component-DataConnectionLayer.md`
line 116 still documents `true` as the default and must be updated. Both edits were
deliberately not made here because this task is scoped to
`src/ScadaLink.DataConnectionLayer`, tests, and this findings file only.
`src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer`, tests, and this findings file only.
### DataConnectionLayer-013 — Misleading XML comment: `RaiseDisconnected` claims thread safety it does not provide
@@ -695,7 +695,7 @@ deliberately not made here because this task is scoped to
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:270-281` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:270-281` |
**Description**
@@ -738,7 +738,7 @@ guard), and it passes against the atomic fix.
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs:325`, `src/ScadaLink.DataConnectionLayer/Adapters/RealOpcUaClient.cs:35-39,79-83` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs:325`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs:35-39,79-83` |
**Description**
@@ -793,7 +793,7 @@ and pass after.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:404-417`, `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:419-493` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:404-417`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:419-493` |
**Description**
@@ -847,7 +847,7 @@ guards the no-backup path.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:606,666-672`, `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:232-240` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:606,666-672`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:232-240` |
**Description**
@@ -901,7 +901,7 @@ the pre-fix code (it returned `Success: true`) and passes after;
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:229-237`, `src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:218-227` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:229-237`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:218-227` |
**Description**
@@ -953,7 +953,7 @@ pre-fix code (the batch throws, no map returned) and passes after;
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:557,564-594,653` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:557,564-594,653` |
**Resolution** — added a `_subscribesInFlight` HashSet mirroring the
existing `_resolutionInFlight` pattern. `HandleSubscribe` now partitions
@@ -1018,7 +1018,7 @@ exactly one `_adapter.SubscribeAsync(tag, ...)` call (and no orphan subscription
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:31,167,177`, `src/ScadaLink.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:163-164` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:31,167,177`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs:163-164` |
**Resolution** — deleted the dead `_subscriptionHandles` field outright.
Subscription bookkeeping lives in `RealOpcUaClient._monitoredItems` /
@@ -1085,7 +1085,7 @@ state.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:653-661,670-688` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:653-661,670-688` |
**Resolution** — split the success branch into "fresh subscribe" vs
"unresolved → resolved promotion": `_unresolvedTags.Remove(...)` is now
@@ -1150,7 +1150,7 @@ test that asserts `_totalSubscribed` / `_resolvedTags` consistency after the
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:626-634,642-687` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:626-634,642-687` |
**Resolution** — `HandleSubscribeCompleted` now detects the
mid-termination race: when `_subscriptionsByInstance.TryGetValue` fails,
@@ -1218,7 +1218,7 @@ for A while the subscribe I/O is in flight, completes the subscribe, and asserts
| Severity | Medium |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs:691-698,991-998` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:691-698,991-998` |
**Resolution** — both call sites now gate
`Timers.StartPeriodicTimer("tag-resolution-retry", ...)` with
+35 -35
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.DeploymentManager` |
| Module | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager` |
| Design doc | `docs/requirements/Component-DeploymentManager.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -109,7 +109,7 @@ The remaining six findings are medium/low: lifecycle-timeout audit gap
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:141-199` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:141-199` |
**Description**
@@ -151,7 +151,7 @@ stuck in `InProgress`. Regression test:
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:186-196` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:186-196` |
**Description**
@@ -190,7 +190,7 @@ error) if persistence still fails. Regression test:
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:155-170` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:155-170` |
**Description**
@@ -237,7 +237,7 @@ Regression test:
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:312-319` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:312-319` |
**Description**
@@ -278,7 +278,7 @@ record is orphaned and must be reconciled. Regression test:
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/OperationLockManager.cs:15-33` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/OperationLockManager.cs:15-33` |
**Description**
@@ -323,7 +323,7 @@ Regression tests: `AcquireAsync_ReleasedLock_RemovesSemaphoreEntry`,
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:84-200,363-368` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:84-200,363-368` |
**Description**
@@ -386,7 +386,7 @@ stale-rejection) when the query fails. Regression tests:
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:334-358,401-406` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:334-358,401-406` |
**Description**
@@ -432,7 +432,7 @@ Regression test: `GetDeploymentComparisonAsync_ProducesStructuredDiff`.
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/ServiceCollectionExtensions.cs:7-14` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs:7-14` |
**Description**
@@ -453,14 +453,14 @@ no equivalent for `DeploymentManagerOptions`.
Add an `IConfiguration` parameter (or a configure callback) to
`AddDeploymentManager` and bind `DeploymentManagerOptions` to a section such as
`ScadaLink:DeploymentManager`, consistent with the other components.
`ScadaBridge:DeploymentManager`, consistent with the other components.
**Resolution**
Resolved 2026-05-16 (commit pending): `AddDeploymentManager()` now calls
`services.AddOptions<DeploymentManagerOptions>()` so `IOptions<DeploymentManagerOptions>`
is always resolvable, and `Host/Program.cs` binds the
`ScadaLink:DeploymentManager` section (exposed as
`ScadaBridge:DeploymentManager` section (exposed as
`ServiceCollectionExtensions.OptionsSection`) via
`services.Configure<DeploymentManagerOptions>(...)` — the same pattern the Host
uses for `SecurityOptions`/`InboundApiOptions`. An earlier attempt added an
@@ -479,7 +479,7 @@ configuration binding. Regression tests:
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:288` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:288` |
**Description**
@@ -515,7 +515,7 @@ would be meaningless).
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/ArtifactDeploymentService.cs:136,194-211` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs:136,194-211` |
**Description**
@@ -546,8 +546,8 @@ the audit log, and the UI summary all reference a single id instead of N+1
unrelated GUIDs (`RetryForSiteAsync`, an independent single-site retry, still
mints its own id). Adding a dedicated `DeploymentId` *column* to
`SystemArtifactDeploymentRecord` was deliberately **not** done: that entity
lives in `ScadaLink.Commons` with its EF mapping in
`ScadaLink.ConfigurationDatabase`, both outside this module's edit scope.
lives in `ZB.MOM.WW.ScadaBridge.Commons` with its EF mapping in
`ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`, both outside this module's edit scope.
Instead the logical `deploymentId` is embedded in the record's free-form
`PerSiteStatus` JSON payload (`{ DeploymentId, Sites }`), which is fully within
this module's control, so the persisted record is correlatable with the
@@ -564,7 +564,7 @@ Regression tests: `DeployToAllSitesAsync_AllPerSiteCommandsShareTheSummaryDeploy
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.DeploymentManager.Tests/DeploymentServiceTests.cs:100-151,155-199` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs:100-151,155-199` |
**Description**
@@ -619,7 +619,7 @@ which asserts on `IAuditService.LogAsync`. Regression tests:
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentManagerOptions.cs:8-9` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptions.cs:8-9` |
**Description**
@@ -664,7 +664,7 @@ the call hung the full 30 s and threw `AskTimeoutException`).
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/ArtifactDeploymentService.cs:108-111` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs:108-111` |
**Description**
@@ -690,14 +690,14 @@ into the artifact (which the design explicitly mandates — SMTP configuration i
a deployable artifact) and **never logs it** — the three log statements in
`DeployToAllSitesAsync` only reference `SiteId`, `SiteName`, `DeploymentId`, and
`ex.Message`, never the credential. There is no defect to fix purely within
`src/ScadaLink.DeploymentManager`. The finding's remaining recommendations are
`src/ZB.MOM.WW.ScadaBridge.DeploymentManager`. The finding's remaining recommendations are
all cross-module and one needs a design decision:
- inter-cluster transport TLS — `ScadaLink.Communication` /
`ScadaLink.ClusterInfrastructure` (Akka remoting + ClusterClient config);
- at-rest encryption of the credential on site SQLite — `ScadaLink.SiteRuntime`
- inter-cluster transport TLS — `ZB.MOM.WW.ScadaBridge.Communication` /
`ZB.MOM.WW.ScadaBridge.ClusterInfrastructure` (Akka remoting + ClusterClient config);
- at-rest encryption of the credential on site SQLite — `ZB.MOM.WW.ScadaBridge.SiteRuntime`
artifact store;
- encrypting the credential field inside the artifact payload — needs the
`SmtpConfigurationArtifact` shape in `ScadaLink.Commons` plus cooperating
`SmtpConfigurationArtifact` shape in `ZB.MOM.WW.ScadaBridge.Commons` plus cooperating
producer (DeploymentManager) and consumer (SiteRuntime) changes, and a
**key-management design decision** (where the encryption key lives, how it
is distributed to sites) that cannot be made unilaterally here.
@@ -733,7 +733,7 @@ option rather than an open defect.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs:86-90` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs:86-90` |
**Description**
@@ -773,7 +773,7 @@ covers DeploymentManager-010), `DeployToAllSitesAsync_PartialFailure_ReportsPerS
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:631-655` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:631-655` |
**Description**
@@ -834,7 +834,7 @@ threaded into `TryReconcileWithSiteAsync`). Regression test:
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:639-651` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:639-651` |
**Description**
@@ -877,7 +877,7 @@ entry, and the site's actually-applied revision all agree. Regression test:
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:562-570` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:562-570` |
**Description**
@@ -913,7 +913,7 @@ would be meaningless).
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:675-682,721-748` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:675-682,721-748` |
**Resolution** — Added a `forceEnabledState` parameter to `ApplyPostSuccessSideEffectsAsync`. The normal deploy path passes `true` (fresh apply legitimately ends in `Enabled`); the reconciliation path passes `false`, so the helper only promotes `NotDeployed → Enabled` and leaves an existing `Disabled` (or `Enabled`) untouched. Regression test `DeployInstanceAsync_Reconciled_DisabledInstance_PreservesDisabledState` exercises the failover scenario and asserts the prior record still flips to `Success` while `Instance.State` stays `Disabled`.
@@ -974,7 +974,7 @@ be marked `Success`.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:328-339,385-396,445-458` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:328-339,385-396,445-458` |
**Resolution (2026-05-28):** added `TryLogLifecycleTimeoutAsync`, a private
helper that mirrors the `DeployFailed` pattern — it calls `_auditService.LogAsync`
@@ -1032,7 +1032,7 @@ also produces an audit entry.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:698-712` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:698-712` |
**Description**
@@ -1077,7 +1077,7 @@ as the actor. Tests green (80/80 in DeploymentManager.Tests).
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:107-111` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:107-111` |
**Resolution (2026-05-28):** `ResolveSiteIdentifierAsync` now throws `InvalidOperationException` (`"Site with ID {siteId} not found; cannot resolve its SiteIdentifier for routing."`) when the `Site` row is missing, instead of returning the numeric id rendered as a string. The deploy path's existing try/catch turns the throw into a `DeploymentStatus.Failed` record carrying the descriptive message (the `DeploymentManager-001`/`-002` cleanup write the failure with `CancellationToken.None`); the lifecycle paths (Disable/Enable/Delete) propagate the exception so the CLI/UI caller surfaces the actual cause to the operator rather than seeing a confusing downstream "unknown site" routing error. The repository contract already returned `Site?`, so the null path is now type-visible at the call site instead of silently papered over.
@@ -1117,7 +1117,7 @@ returns `Site?`, so the null path is type-visible; just don't paper over it.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/DeploymentService.cs:178-194` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs:178-194` |
**Resolution (2026-05-28):** The transient `Pending` write was dropped — the deployment record is now created directly in `DeploymentStatus.InProgress`, which collapses the start of the deploy into a single `AddDeploymentRecordAsync` + `SaveChangesAsync` + `NotifyStatusChange` (instead of two writes back-to-back). The flattening, validation, and `TryReconcileWithSiteAsync` round-trip have all completed before the insert, and the deploy command is sent immediately after, so `Pending` carried no operational meaning between the two writes. `InProgress` retains its documented "sent to site, awaiting response" semantics. Eliminating the extra `SaveChangesAsync` round-trip also removes the `Pending``InProgress` flicker the CentralUI-006 deployment-status page used to render via the second `IDeploymentStatusNotifier.NotifyStatusChanged` invocation.
@@ -1162,7 +1162,7 @@ Either:
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/ArtifactDeploymentService.cs:82-144,169-173` |
| Location | `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs:82-144,169-173` |
**Resolution (2026-05-28):** Hoisted the global artifact queries (shared scripts, external systems + methods, DB connections, notification lists, SMTP configurations) out of the per-site loop into a new private `FetchGlobalArtifactsAsync` that produces a `GlobalArtifactSnapshot` record. `DeployToAllSitesAsync` now calls it ONCE before the loop and threads the snapshot through a new prefetched-globals overload of `BuildDeployArtifactsCommandAsync`; the public single-site overload keeps the prior fetch-then-build behaviour for `RetryForSiteAsync`. Only the per-site data-connection query remains inside the loop. Regression tests `DeployToAllSitesAsync_HoistsGlobalArtifactQueriesOutOfPerSiteLoop` (three sites; pins exactly-one call to each global getter and one per-site call to `GetDataConnectionsBySiteIdAsync`) and `RetryForSiteAsync_SingleSitePath_StillRunsTheGlobalQueriesOnce` (single-site path still owns its own fetch).
@@ -1207,9 +1207,9 @@ N-site deployment.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.DeploymentManager.Tests/DeploymentServiceTests.cs:966-1075`, `tests/ScadaLink.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs:196-217` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs:966-1075`, `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs:196-217` |
**Resolution (2026-05-28):** Replaced the `static` counters with per-test instance state. Introduced `ReconcileProbeCounters` and `SerializationProbeCounters` (in `DeploymentServiceTests`) and `ArtifactProbeRecorder` (in `ArtifactDeploymentServiceTests`); each probe actor now takes the counter object as its first constructor argument. Every test instantiates a fresh counter local, passes it via `Props.Create(() => new ReconcileProbeActor(counters, ...))`, and reads the counts directly off `counters` — no shared static fields remain. `ReconcileProbeActor`'s counter increments swap to `Interlocked.Increment` for the cross-thread CAS, and `SerializationProbeActor` retains its lock on a per-test `Gate`. All 85 `ScadaLink.DeploymentManager.Tests` continue to pass after the refactor.
**Resolution (2026-05-28):** Replaced the `static` counters with per-test instance state. Introduced `ReconcileProbeCounters` and `SerializationProbeCounters` (in `DeploymentServiceTests`) and `ArtifactProbeRecorder` (in `ArtifactDeploymentServiceTests`); each probe actor now takes the counter object as its first constructor argument. Every test instantiates a fresh counter local, passes it via `Props.Create(() => new ReconcileProbeActor(counters, ...))`, and reads the counts directly off `counters` — no shared static fields remain. `ReconcileProbeActor`'s counter increments swap to `Interlocked.Increment` for the cross-thread CAS, and `SerializationProbeActor` retains its lock on a per-test `Gate`. All 85 `ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests` continue to pass after the refactor.
**Description**
+35 -35
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.ExternalSystemGateway` |
| Module | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway` |
| Design doc | `docs/requirements/Component-ExternalSystemGateway.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -120,7 +120,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | Critical |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:109`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:81` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:109`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/DatabaseGateway.cs:81` |
**Description**
@@ -176,7 +176,7 @@ transient-retry paths. Fixed by the commit whose message references
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:130`, `src/ScadaLink.ExternalSystemGateway/ServiceCollectionExtensions.cs:13` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:130`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs:13` |
**Description**
@@ -221,7 +221,7 @@ swallowed as transient. Regression tests:
`Call_CallerCancellation_IsNotMisreportedAsTimeout`.
Note (partial scope): the per-*system* `Timeout` field on `ExternalSystemDefinition`
remains unimplemented — adding it requires a change to `ScadaLink.Commons`, which is
remains unimplemented — adding it requires a change to `ZB.MOM.WW.ScadaBridge.Commons`, which is
outside this module's edit scope. Until that entity field exists, the configured
`DefaultHttpTimeout` is the effective per-call limit for every system. A follow-up
against the Commons module should add the `Timeout` field and have `InvokeHttpAsync`
@@ -234,7 +234,7 @@ prefer it over the default. This is a tracked follow-up, not a regression.
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:84-117` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:84-117` |
**Description**
@@ -282,7 +282,7 @@ is flipped back to `true`.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:114-115`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:86-87` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:114-115`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/DatabaseGateway.cs:86-87` |
**Description**
@@ -291,7 +291,7 @@ is flipped back to `true`.
(`MaxRetries > 0 ? ... : null`, `RetryDelay > TimeSpan.Zero ? ... : null`), otherwise
falling back to the S&F defaults. The site-side repository that supplies these
definitions, `SiteExternalSystemRepository.MapExternalSystem`
(`src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs:194`), never
(`src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs:194`), never
reads `MaxRetries`/`RetryDelay` from SQLite at all — the constructed entities always
have `MaxRetries == 0` and `RetryDelay == TimeSpan.Zero`. As a result, at sites the
per-system retry settings the design doc requires are *always* discarded and the
@@ -331,7 +331,7 @@ module (outside this module's edit scope) — until then, sites still supply
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:133-167` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:133-167` |
**Description**
@@ -368,7 +368,7 @@ are disposed; both were verified to fail before the `using` wrappers were added.
| Severity | Medium — partially re-triaged: trailing-slash bug fixed; path-templating sub-issue is a design decision (see Resolution) |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:180-196` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:180-196` |
**Description**
@@ -419,7 +419,7 @@ in this finding) is fully resolved.
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:167-177` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:167-177` |
**Description**
@@ -459,7 +459,7 @@ size cap alone closes the inflation/disclosure vector.
| Severity | Medium — re-triaged: root cause already fixed in current source (see Resolution) |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ErrorClassifier.cs:24-30`, `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:157-159` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ErrorClassifier.cs:24-30`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:157-159` |
**Description**
@@ -513,7 +513,7 @@ the S&F buffer remains empty (the cancelled work is not retried). The existing
| Severity | Medium — re-triaged: root cause subsumed by the ExternalSystemGateway-003 dispatch redesign (see Resolution) |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:109-117` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:109-117` |
**Description**
@@ -564,7 +564,7 @@ semantics shared via `InvokeHttpAsync`.
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:48-50` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/DatabaseGateway.cs:48-50` |
**Description**
@@ -600,7 +600,7 @@ exception propagates; it was verified to fail before the `try/catch` was added.
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:360-374`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:169-176` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:360-374`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/DatabaseGateway.cs:169-176` |
**Description**
@@ -626,7 +626,7 @@ cleaner of the two options, and one that avoids the staleness hazard a
deployment-invalidated cache would introduce.
Three name-keyed methods were added to `IExternalSystemRepository`
(`ScadaLink.Commons`): `GetExternalSystemByNameAsync(name)`,
(`ZB.MOM.WW.ScadaBridge.Commons`): `GetExternalSystemByNameAsync(name)`,
`GetMethodByNameAsync(externalSystemId, methodName)` and
`GetDatabaseConnectionByNameAsync(name)`. The connection lookup belongs on the same
interface because database connection definitions are already part of
@@ -636,12 +636,12 @@ followed rather than introducing a new interface.
Both implementers of the interface were updated:
- `ScadaLink.ConfigurationDatabase.ExternalSystemRepository` — all three are genuine
- `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.ExternalSystemRepository` — all three are genuine
server-side keyed queries (`FirstOrDefaultAsync(x => x.Name == name)`, the
method lookup additionally scoped by `ExternalSystemDefinitionId`), matching the
existing `GetMethodByNameAsync` / `GetListByNameAsync` / `GetSharedScriptByNameAsync`
convention in the other Central repositories.
- `ScadaLink.SiteRuntime.SiteExternalSystemRepository``GetExternalSystemByNameAsync`
- `ZB.MOM.WW.ScadaBridge.SiteRuntime.SiteExternalSystemRepository``GetExternalSystemByNameAsync`
and `GetDatabaseConnectionByNameAsync` are genuine single-row indexed SQLite queries
(`WHERE name = @name`; both tables have `name` as the PRIMARY KEY).
`GetMethodByNameAsync` resolves the named method from the parent system's
@@ -678,9 +678,9 @@ keyed query is deliberately broken):
`StubResolution` / `StubConnection` helpers), so the full gateway suite now exercises
and protects the keyed-lookup resolution path.
`dotnet build ScadaLink.slnx` is clean; `ScadaLink.ExternalSystemGateway.Tests` (54),
`ScadaLink.ConfigurationDatabase.Tests` (106), `ScadaLink.SiteRuntime.Tests` (196) and
`ScadaLink.Commons.Tests` (226) all pass.
`dotnet build ZB.MOM.WW.ScadaBridge.slnx` is clean; `ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests` (54),
`ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests` (106), `ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests` (196) and
`ZB.MOM.WW.ScadaBridge.Commons.Tests` (226) all pass.
### ExternalSystemGateway-012 — Permanent-failure logging requirement is not met; `_logger` is injected but unused
@@ -689,7 +689,7 @@ keyed query is deliberately broken):
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:24,169-177`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:22` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:24,169-177`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/DatabaseGateway.cs:22` |
**Description**
@@ -734,7 +734,7 @@ against over-logging transient failures).
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemGatewayOptions.cs:9,12`, `src/ScadaLink.ExternalSystemGateway/ServiceCollectionExtensions.cs:13` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptions.cs:9,12`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs:13` |
**Description**
@@ -776,7 +776,7 @@ to fail before the wiring was added.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs:1`, `tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs:1`, `tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs` |
**Description**
@@ -834,7 +834,7 @@ against regression.
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:120-127`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:102-108` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:120-127`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/DatabaseGateway.cs:102-108` |
**Description**
@@ -907,7 +907,7 @@ message carries the bounded default (99) and never `0`.
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ServiceCollectionExtensions.cs:21-29` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs:21-29` |
**Description**
@@ -919,7 +919,7 @@ adds the configuration to *every* `HttpClient`/`IHttpClientFactory` client creat
anywhere in the host, regardless of name.
The Host registers the External System Gateway alongside other components that also
use `IHttpClientFactory` — notably `ScadaLink.NotificationService` (`OAuth2TokenService`
use `IHttpClientFactory` — notably `ZB.MOM.WW.ScadaBridge.NotificationService` (`OAuth2TokenService`
and its `ServiceCollectionExtensions` call `AddHttpClient`). With the ESG registration
present, the OAuth2 token client (and any future `HttpClient` consumer in the host)
has its **primary handler replaced** by a `SocketsHttpHandler` whose
@@ -967,7 +967,7 @@ does; it was verified to fail before the fix.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:324-333` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:324-333` |
**Description**
@@ -1004,7 +1004,7 @@ captured request URI has no trailing `?`; it was verified to fail before the fix
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:176`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:151` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:176`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/DatabaseGateway.cs:151` |
**Resolution** — Wrapped the `JsonSerializer.Deserialize<...>(message.PayloadJson)` call in both `ExternalSystemClient.DeliverBufferedAsync` and `DatabaseGateway.DeliverBufferedAsync` in a `try`/`catch (JsonException)` block. A `JsonException` is by definition permanent (the same payload bytes always deserialize identically), so the catch branch logs at `LogError` and returns `false`, parking the message via the S&F engine instead of letting it throw and be retried as a transient failure. Regression tests `DeliverBuffered_MalformedJsonPayload_ReturnsFalseSoMessageParks` were added to both `ExternalSystemClientTests` and `DatabaseGatewayTests` — each feeds a truncated `PayloadJson` to the handler and asserts `delivered == false` and that no exception escapes.
@@ -1066,7 +1066,7 @@ message parks) and that no exception escapes the handler.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:226,257-264`, `src/ScadaLink.ExternalSystemGateway/ServiceCollectionExtensions.cs:90-102` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:226,257-264`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs:90-102` |
**Resolution (2026-05-28):** Set `client.Timeout = Timeout.InfiniteTimeSpan` immediately after `_httpClientFactory.CreateClient($"ExternalSystem_{system.Name}")` in `ExternalSystemClient.InvokeHttpAsync`, disabling the framework's 100 s default so the per-call `CancellationTokenSource(_options.DefaultHttpTimeout)` linked CTS already built below is the sole timeout source. An operator-configured `DefaultHttpTimeout` greater than 100 s is now honoured verbatim instead of being silently clipped and misclassified as a transient "connection error". Kept the fix local to the allowed file (`ExternalSystemClient.cs`) rather than touching `ServiceCollectionExtensions.cs`/`GatewayHttpClientConfigurator`. Regression test `Call_DisablesHttpClientFrameworkTimeoutSoLongTimeoutsArentClipped` asserts the rented client starts with the framework's 100 s default and is set to `Timeout.InfiniteTimeSpan` after `InvokeHttpAsync` runs.
@@ -1119,7 +1119,7 @@ and produces a `"Timeout calling..."` (not `"Connection error to..."`) error.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:185-193` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/DatabaseGateway.cs:185-193` |
**Resolution (2026-05-28):** `JsonElementToParameterValue` now probes `TryGetInt64``TryGetDecimal``GetDouble`, so a JSON number that fits in `decimal` materialises as a `decimal` (preserving the script's authored precision on cached-write retries) and only genuinely out-of-decimal-range values fall through to `double`. Regression test `JsonElementToParameterValue_DecimalShapedNumber_PreservesPrecisionViaDecimal` round-trips `1234567890.1234567890` through a `JsonElement` and asserts the result is a `decimal` carrying the original precision; companion tests guard the long-fast-path and the out-of-range-double fallback.
@@ -1183,12 +1183,12 @@ that happens to encode a number (already correctly returns `string`).
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:385-415` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:385-415` |
**Resolution (2026-05-28):** `ApplyAuth` is now an instance method that uses
the existing `_logger`. Three previously-silent fail-open paths now emit a
`LogWarning` so an operator debugging a recurring 401 sees the cause inside
ScadaLink: (1) empty `AuthConfiguration` for `AuthType=apikey`/`basic`,
ScadaBridge: (1) empty `AuthConfiguration` for `AuthType=apikey`/`basic`,
(2) unknown `AuthType` (anything except `apikey`/`basic`/`none`),
(3) malformed Basic config (no `:` separator). The `AuthConfiguration`
value is NEVER included in the log message. `AuthType="none"` remains
@@ -1217,7 +1217,7 @@ Effectively the gateway treats every misconfiguration as "send anonymously" and
relies on the remote system rejecting it with a 401/403. That is a defensible default
on its own, but combined with `-007`'s 2 KB error-body cap and the fact that no audit
or warning is emitted, an operator debugging "why does my external system always
return 401" has nothing to go on inside ScadaLink — the gateway never says it failed
return 401" has nothing to go on inside ScadaBridge — the gateway never says it failed
to apply auth. For `AuthType = "none"` (the design's expected sentinel for
unauthenticated systems) the fall-through is correct; the failure mode is misconfig.
@@ -1240,7 +1240,7 @@ ever leaked in the warning text would close the test gap as well.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:233` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:233` |
**Resolution (2026-05-28):** Added a `ValidateHttpMethod` helper called at the top of `InvokeHttpAsync` that rejects any verb outside the documented `GET/POST/PUT/PATCH/DELETE` allowlist (matching ESG-023's design-doc reconciliation) with a clear `ArgumentException` naming the offending verb. Allowlist is a `HashSet<string>` with `OrdinalIgnoreCase` so the operator-authored entity column is case-insensitive. Regression tests `Call_UnsupportedHttpMethod_ThrowsArgumentException` (Theory: FOO/DLETE/GIT/OPTIONS/HEAD) and `Call_DocumentedHttpMethod_IsAccepted` (Theory: GET/get/Post/PATCH/delete) cover the rejection and the case-insensitive accept paths.
@@ -1279,7 +1279,7 @@ is in the body branch but not the design-doc list; see finding 023).
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:241`, `docs/requirements/Component-ExternalSystemGateway.md:43` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs:241`, `docs/requirements/Component-ExternalSystemGateway.md:43` |
**Resolution (2026-05-28):** Doc-only fix; confirmed PATCH is wired in `ExternalSystemClient.cs:258-260` alongside POST/PUT for body serialization. Added `PATCH` to the design doc's HTTP-method list (line 42) and updated the body/query-parameter sentence (line 75) so the documented set matches the code's `body = POST/PUT/PATCH; query = GET/DELETE` split.
+34 -34
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.HealthMonitoring` |
| Module | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring` |
| Design doc | `docs/requirements/Component-HealthMonitoring.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -119,7 +119,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs:104`, `src/ScadaLink.HealthMonitoring/HealthReportSender.cs:79` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs:104`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs:79` |
**Description**
@@ -161,7 +161,7 @@ test. No StoreAndForward source was modified (existing public API only).
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/SiteHealthState.cs:11`, `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:86`, `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:137` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs:11`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:86`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:137` |
**Description**
@@ -209,7 +209,7 @@ and no lost updates.
| Severity | Medium — re-triaged: already resolved as a side-effect of HealthMonitoring-002. |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:45-103` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:45-103` |
**Description**
@@ -251,7 +251,7 @@ updates and no torn snapshots. No further code change was required for this find
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:146-148`, `src/ScadaLink.HealthMonitoring/SiteHealthState.cs:21`, `src/ScadaLink.HealthMonitoring/ICentralHealthAggregator.cs:16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:146-148`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs:21`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ICentralHealthAggregator.cs:16` |
**Description**
@@ -289,7 +289,7 @@ number, so readers can reason about the 60s offline grace.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs:48-81`, `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:149` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs:48-81`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:149` |
**Description**
@@ -333,7 +333,7 @@ offline after 10 minutes) verify the behaviour.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/HealthReportSender.cs:28`, `src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs:32` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs:28`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs:32` |
**Description**
@@ -378,7 +378,7 @@ the pre-fix code, which had no `TimeProvider` parameter).
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:86-99` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:86-99` |
**Description**
@@ -422,7 +422,7 @@ verifies the registration; `MarkHeartbeat_KeepsSiteOnline_BetweenReports` and
| Severity | Medium — re-triaged: already resolved as a side-effect of HealthMonitoring-002. |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:146-158` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:146-158` |
**Description**
@@ -460,7 +460,7 @@ what the HealthMonitoring-002 change did, so no further code change was required
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.HealthMonitoring.Tests/` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/` |
**Description**
@@ -502,8 +502,8 @@ Resolved 2026-05-16 (commit `pending`). Added the missing coverage:
`SetStoreAndForwardDepths`.
The `SiteHealthReportReplica` idempotency item is **out of scope** for this module:
`SiteHealthReportReplica` is declared in `ScadaLink.Commons` and published/consumed by
`CentralCommunicationActor` in the `ScadaLink.Communication` module — the
`SiteHealthReportReplica` is declared in `ZB.MOM.WW.ScadaBridge.Commons` and published/consumed by
`CentralCommunicationActor` in the `ZB.MOM.WW.ScadaBridge.Communication` module — the
HealthMonitoring module itself has no replication code. Replica double-delivery
idempotency is already covered by `ProcessReport`'s sequence-number guard
(`ProcessReport_RejectsEqualSequence`, `ProcessReport_RejectsStaleReport_WhenSequenceNotGreater`);
@@ -517,7 +517,7 @@ The HealthMonitoring test suite now stands at 47 passing tests (was 30).
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/HealthReportSender.cs:70-87` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs:70-87` |
**Description**
@@ -560,7 +560,7 @@ the pre-fix bare `catch { }` (logged-entry collection was empty).
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/ServiceCollectionExtensions.cs:42-46` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ServiceCollectionExtensions.cs:42-46` |
**Description**
@@ -593,7 +593,7 @@ build and all 50 tests passing.
| Severity | Low — re-triaged: already resolved as a side-effect of HealthMonitoring-002. |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/SiteHealthState.cs:11` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs:11` |
**Description**
@@ -620,7 +620,7 @@ HealthMonitoring-007 fixes. `SiteHealthState.LatestReport` is already declared
`SiteHealthReport? LatestReport { get; init; }` (the recommendation's "make it properly
nullable" option) with an XML doc explaining the `null` case ("known only via heartbeats,
has not yet sent a report"). A codebase-wide search confirms no `null!` suppression
remains anywhere in `src/ScadaLink.HealthMonitoring`. This is exactly the change
remains anywhere in `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring`. This is exactly the change
HealthMonitoring-002 made when converting `SiteHealthState` to an immutable record, so
the contract is now honest and no further code change was required.
@@ -631,7 +631,7 @@ the contract is now honest and no further code change was required.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:194-196` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:194-196` |
**Description**
@@ -680,11 +680,11 @@ would have returned 30s against the pre-fix code.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/HealthMonitoringOptions.cs:3-20`, `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:196`, `src/ScadaLink.HealthMonitoring/HealthReportSender.cs:67`, `src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs:63` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptions.cs:3-20`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:196`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs:67`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs:63` |
**Description**
`HealthMonitoringOptions` is bound from the `ScadaLink:HealthMonitoring` config
`HealthMonitoringOptions` is bound from the `ScadaBridge:HealthMonitoring` config
section (`SiteServiceRegistration.BindSharedOptions`) with no validation —
no `IValidateOptions<HealthMonitoringOptions>`, no `ValidateDataAnnotations`, no
`ValidateOnStart`. `ReportInterval`, `OfflineTimeout`, and `CentralOfflineTimeout`
@@ -713,7 +713,7 @@ the hosted service with an opaque `ArgumentOutOfRangeException`. Added
`HealthMonitoringOptionsValidator : IValidateOptions<HealthMonitoringOptions>` that
rejects non-positive `ReportInterval`/`OfflineTimeout`/`CentralOfflineTimeout` and a
`CentralOfflineTimeout` shorter than `OfflineTimeout`, each failure naming the
`ScadaLink:HealthMonitoring` config key. It is registered (idempotently, via
`ScadaBridge:HealthMonitoring` config key. It is registered (idempotently, via
`TryAddEnumerable`) by all three `ServiceCollectionExtensions` registration methods,
so it fires when the hosted services resolve `IOptions.Value` at startup — failing
fast with a clear message. (`ValidateOnStart()` lives in the Host module's binding
@@ -730,7 +730,7 @@ intervals and the `CentralOfflineTimeout < OfflineTimeout` case.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:122-130`, `src/ScadaLink.HealthMonitoring/SiteHealthState.cs:27` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:122-130`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthState.cs:27` |
**Description**
@@ -784,7 +784,7 @@ the first would not compile against the pre-fix non-nullable field), and
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs:151` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs:151` |
**Description**
@@ -828,7 +828,7 @@ constructor.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/HealthReportSender.cs:140-154`, `src/ScadaLink.HealthMonitoring/SiteHealthCollector.cs:146-153` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthReportSender.cs:140-154`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/SiteHealthCollector.cs:146-153` |
**Resolution (2026-05-28):** Wrapped `_transport.Send(reportWithSeq)` in an inner try/catch that, on failure, atomically restores the captured per-interval counts via a new `ISiteHealthCollector.AddIntervalCounters(scriptErrors, alarmErrors, deadLetters, siteAuditWriteFailures, auditRedactionFailures)` API backed by `Interlocked.Add`. Concurrent increments arriving during the Send accumulate against the zero left by `CollectReport`'s `Exchange`; the restore Add sums correctly with them. The new interface method ships with a default no-op so existing test fakes (`CountCapturingHealthCollector` etc.) keep compiling without per-fake updates. Regression test `HealthReportSenderTests.SendFailure_PreservesIntervalCountersForNextReport` pre-populates all five counters, makes the first Send throw, and asserts the next successful report carries the original counts (2 / 1 / 3 / 1 / 2).
@@ -882,7 +882,7 @@ report includes the previously-failed interval's `ScriptErrorCount`.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs:87-98` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs:87-98` |
**Resolution (2026-05-28):** Same shape of fix as HealthMonitoring-017 — `_aggregator.ProcessReport(reportWithSeq)` now sits inside an inner try/catch that, on failure, calls `_collector.AddIntervalCounters(...)` with the captured report's counts. Reuses the same `ISiteHealthCollector.AddIntervalCounters` API; no extra collector surface. Regression test `CentralHealthReportLoopTests.ProcessReportFailure_PreservesIntervalCountersForNextReport` pre-populates all five counters, makes the first `ProcessReport` throw, and asserts the next successful report carries the original counts.
@@ -919,7 +919,7 @@ collector API once it lands.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `docs/requirements/Component-HealthMonitoring.md:39,40`, `src/ScadaLink.HealthMonitoring/ICentralHealthAggregator.cs`, `src/ScadaLink.AuditLog/Central/AuditCentralHealthSnapshot.cs:39-58` |
| Location | `docs/requirements/Component-HealthMonitoring.md:39,40`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ICentralHealthAggregator.cs`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditCentralHealthSnapshot.cs:39-58` |
**Resolution (2026-05-28):** Took the simpler doc-removal path rather than surfacing the metrics through a new HealthMonitoring API. Removed `SiteAuditTelemetryStalled` and `CentralAuditWriteFailures` from the Monitored Metrics table, the Audit Log KPIs section, and the Audit Log Dependencies entry in `Component-HealthMonitoring.md`. The two metrics remain internal to `AuditLog`'s `AuditCentralHealthSnapshot` — promoting them to dashboard tiles is a separate feature, out of scope here.
@@ -937,11 +937,11 @@ Tracing the code:
- `SiteAuditTelemetryStalled` is published by `SiteAuditReconciliationActor`,
picked up by `SiteAuditTelemetryStalledTracker`, and latched into
`AuditCentralHealthSnapshot._stalled` (a `ConcurrentDictionary<string, bool>`
in the `ScadaLink.AuditLog` assembly).
in the `ZB.MOM.WW.ScadaBridge.AuditLog` assembly).
- `CentralAuditWriteFailures` is incremented inside `AuditCentralHealthSnapshot`
via `ICentralAuditWriteFailureCounter.Increment()` (also in `ScadaLink.AuditLog`).
via `ICentralAuditWriteFailureCounter.Increment()` (also in `ZB.MOM.WW.ScadaBridge.AuditLog`).
Neither metric is referenced anywhere in `src/ScadaLink.HealthMonitoring/`:
Neither metric is referenced anywhere in `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/`:
- `ICentralHealthAggregator` does not expose them.
- `SiteHealthCollector` has no central counterpart (it is site-only).
- `SiteHealthReport` has no `SiteAuditTelemetryStalled` / `CentralAuditWriteFailures`
@@ -955,7 +955,7 @@ design doc places these metrics under HealthMonitoring's responsibility
Dependencies section's claim that Health Monitoring provides "the
central-computed `CentralAuditWriteFailures` / `AuditRedactionFailure` metrics"
is false for `CentralAuditWriteFailures`: nothing under
`src/ScadaLink.HealthMonitoring/` knows about it.
`src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/` knows about it.
**Recommendation**
@@ -980,7 +980,7 @@ counter and per-site stalled state.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:128-147` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:128-147` |
**Resolution (2026-05-28):** `MarkHeartbeat` now branches on
`existing.IsOnline`: when transitioning offline-to-online it anchors
@@ -1035,7 +1035,7 @@ online.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.HealthMonitoring/CentralHealthReportLoop.cs:22`, `src/ScadaLink.HealthMonitoring/CentralHealthAggregator.cs:224-226` |
| Location | `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthReportLoop.cs:22`, `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/CentralHealthAggregator.cs:224-226` |
**Resolution (2026-05-28):** `CentralHealthReportLoop.CentralSiteId` is now `"$central"` instead of `"central"`. The leading `$` is forbidden in operator-set `Site.SiteIdentifier` values (which are plain identifiers), so the synthetic central self-report cannot collide with a real site whose identifier happens to be the bare word `"central"`. The collision case the finding called out — two reports clobbering each other in the aggregator keyspace via the sequence-number guard and a real site inheriting `CentralOfflineTimeout` and staying falsely-online for an extra two minutes — is now impossible. The aggregator (`CentralHealthAggregator.CheckForOfflineSites`), the Central UI health dashboard (`Monitoring/Health.razor`), and every test reference the constant rather than the literal string, so the value change is local — no consumer code needed updating. Existing `CentralHealthAggregatorTests` and `CentralHealthReportLoopTests` already use the constant, so they continue to pin the central-self-report identity through the new sentinel.
@@ -1088,9 +1088,9 @@ preserved.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.HealthMonitoring.Tests/CentralHealthReportLoopTests.cs:32-42` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/CentralHealthReportLoopTests.cs:32-42` |
**Resolution (2026-05-28):** Picked the less-invasive recommended option (a) — kept the real-time `PeriodicTimer` but replaced the fixed-budget `Task.Delay` with a generous poll-until-condition helper. `RunLoopUntil(loop, condition, maxWait = 5s)` starts the hosted service, polls `condition` every 25 ms with a 5 s outer cap, and stops cleanly when met; `GeneratesCentralReports_WhenSelfIsPrimary`, `AssignsMonotonicSequenceNumbers`, and `ProcessReportFailure_PreservesIntervalCountersForNextReport` now use it. The legacy `RunLoopBriefly` retains a fixed wait (≥ 1 s) for the two tests that assert *absence* of reports (`GeneratesNoReports_WhenNotPrimary`, `SetsActiveNodeFlag_EvenWhenNotPrimary`) since there is no condition to poll for. Refactoring the production loop to consume `TimeProvider.CreateTimer` (option b) was rejected for batch scope — it would require a production change for what is currently a low-severity test-hygiene gap. All 73 `ScadaLink.HealthMonitoring.Tests` pass; the new generous budget tolerates slow CI runners.
**Resolution (2026-05-28):** Picked the less-invasive recommended option (a) — kept the real-time `PeriodicTimer` but replaced the fixed-budget `Task.Delay` with a generous poll-until-condition helper. `RunLoopUntil(loop, condition, maxWait = 5s)` starts the hosted service, polls `condition` every 25 ms with a 5 s outer cap, and stops cleanly when met; `GeneratesCentralReports_WhenSelfIsPrimary`, `AssignsMonotonicSequenceNumbers`, and `ProcessReportFailure_PreservesIntervalCountersForNextReport` now use it. The legacy `RunLoopBriefly` retains a fixed wait (≥ 1 s) for the two tests that assert *absence* of reports (`GeneratesNoReports_WhenNotPrimary`, `SetsActiveNodeFlag_EvenWhenNotPrimary`) since there is no condition to poll for. Refactoring the production loop to consume `TimeProvider.CreateTimer` (option b) was rejected for batch scope — it would require a production change for what is currently a low-severity test-hygiene gap. All 73 `ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests` pass; the new generous budget tolerates slow CI runners.
**Description**
@@ -1125,7 +1125,7 @@ module's tests.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `tests/ScadaLink.HealthMonitoring.Tests/SiteHealthCollectorTests.cs:117-122` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/SiteHealthCollectorTests.cs:117-122` |
**Resolution (2026-05-28):** Renamed the test method from `StoreAndForwardBufferDepths_IsEmptyPlaceholder` to `StoreAndForwardBufferDepths_DefaultsToEmpty_WhenSetterNotCalled` so the name describes what it actually asserts — the default-state contract of a fresh collector. No behaviour change; the body still constructs a collector without calling `SetStoreAndForwardDepths` and asserts `Empty(report.StoreAndForwardBufferDepths)`.
+72 -72
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.Host` |
| Module | `src/ZB.MOM.WW.ScadaBridge.Host` |
| Design doc | `docs/requirements/Component-Host.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -12,12 +12,12 @@
## Summary
The Host module is the composition root for the entire ScadaLink system: a single
The Host module is the composition root for the entire ScadaBridge system: a single
binary whose behaviour (`Central` vs `Site`) is driven entirely by configuration. The
implementation is generally faithful to `Component-Host.md` — startup validation,
role-based registration, Serilog enrichment, Windows Service support, dead-letter
monitoring, CoordinatedShutdown, and gRPC hosting on site nodes are all present and
backed by a solid test suite (`tests/ScadaLink.Host.Tests`).
backed by a solid test suite (`tests/ZB.MOM.WW.ScadaBridge.Host.Tests`).
The most significant problem is the readiness endpoint: `/health/ready` runs **all**
registered health checks, including the leader-only `active-node` check, so a fully
@@ -74,7 +74,7 @@ call passes `default` for `CancellationToken`, so a SIGTERM during the
bounded-retry window is ignored for up to ~2 minutes (Host-019);
`LoggerConfigurationFactory` layers `MinimumLevel.Is` over
`ReadFrom.Configuration`, so any `Serilog:MinimumLevel` an operator sets is
silently overridden by `ScadaLink:Logging:MinimumLevel` (Host-020); the
silently overridden by `ScadaBridge:Logging:MinimumLevel` (Host-020); the
shipped `appsettings.json` carries a Microsoft `Logging:LogLevel` block but
Serilog is the only logger provider and the section is dead config (Host-021);
and `ParseLevel` silently swallows an unrecognised `MinimumLevel` value (e.g.
@@ -108,7 +108,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| 7 | Design-document adherence | ☑ | Re-review: REQ-HOST-7 site-shutdown ordering — stop accepting new streams, cancel active streams via `ApplicationStopping`, then tear down actors — is not wired in `Program.cs` (Host-017). |
| 8 | Code organization & conventions | ☑ | Re-review: `NodeOptions.NodeName` is absent from the shipped per-role configs even though it stamps `AuditLog.SourceNode` (Host-018); the appsettings `Logging:LogLevel` Microsoft section is dead config under Serilog (Host-021). |
| 9 | Testing coverage | ☑ | Strong existing suite. No coverage for the Site `CentralContactPoints` second-entry rule (Host-016), the site-shutdown ordering (Host-017), the `NodeName`-absent shipped config (Host-018), the unused `CancellationToken` parameter (Host-019), the `MinimumLevel.Is` override semantics (Host-020) or the `ParseLevel` silent fallback (Host-022). |
| 10 | Documentation & comments | ☑ | Re-review: layered `MinimumLevel.Is` / `ReadFrom.Configuration` semantics are not surfaced — an operator-set `Serilog:MinimumLevel` is silently overridden by `ScadaLink:Logging:MinimumLevel` (Host-020); `ParseLevel` silently coerces a misspelled level to `Information` with no warning (Host-022). |
| 10 | Documentation & comments | ☑ | Re-review: layered `MinimumLevel.Is` / `ReadFrom.Configuration` semantics are not surfaced — an operator-set `Serilog:MinimumLevel` is silently overridden by `ScadaBridge:Logging:MinimumLevel` (Host-020); `ParseLevel` silently coerces a misspelled level to `Information` with no warning (Host-022). |
## Findings
@@ -119,7 +119,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Program.cs:135-145` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:135-145` |
**Description**
@@ -165,7 +165,7 @@ response body; it failed before the fix and passes after. Full Host suite green
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:70-108`, `docs/requirements/Component-Host.md` REQ-HOST-6 |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:70-108`, `docs/requirements/Component-Host.md` REQ-HOST-6 |
**Description**
@@ -173,7 +173,7 @@ REQ-HOST-6 states the Host "must configure the Akka.NET actor system using
Akka.Hosting with ... **Persistence**: Configured with the appropriate journal and
snapshot store (SQL for central, SQLite for site)." The HOCON built in
`AkkaHostedService.StartAsync` contains no `akka.persistence` section, no journal and
no snapshot-store plugin, and `ScadaLink.Host.csproj` references neither
no snapshot-store plugin, and `ZB.MOM.WW.ScadaBridge.Host.csproj` references neither
`Akka.Persistence.Hosting` nor any persistence plugin (the design doc Dependencies
list `Akka.Persistence.Hosting`). A repo-wide search finds **no** `PersistentActor` /
`ReceivePersistentActor` subclasses — the system deliberately uses custom SQLite
@@ -193,7 +193,7 @@ add the plugin packages and HOCON. Either way, code and doc must agree.
Resolved 2026-05-16 (commit `<pending>`). Confirmed accurate: a repo-wide search
finds **no** `PersistentActor` / `ReceivePersistentActor` subclasses anywhere in
`src/`, no `akka.persistence` section in the HOCON built by
`AkkaHostedService.StartAsync`, and `ScadaLink.Host.csproj` references no
`AkkaHostedService.StartAsync`, and `ZB.MOM.WW.ScadaBridge.Host.csproj` references no
persistence plugin packages. The Host code is correct and internally consistent;
the defect was a stale design doc. Fix: updated `docs/requirements/Component-Host.md`
— REQ-HOST-6 no longer lists Persistence as a configured subsystem and now carries
@@ -211,15 +211,15 @@ agree; no code or test change required.
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Host/appsettings.Central.json:20-31` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json:20-31` |
**Description**
`appsettings.Central.json` contains real-looking secrets in plaintext, checked into
source control: SQL Server passwords in the `ConfigurationDb` / `MachineDataDb`
connection strings (`Password=ScadaLink_Dev1#`), an LDAP service-account password
connection strings (`Password=ScadaBridge_Dev1#`), an LDAP service-account password
(`LdapServiceAccountPassword: "password"`), and a JWT signing key
(`JwtSigningKey: "scadalink-dev-jwt-signing-key-..."`). Even though these are
(`JwtSigningKey: "scadabridge-dev-jwt-signing-key-..."`). Even though these are
intended as development defaults, shipping them in the default config invites them
being reused verbatim in production, and a committed JWT signing key allows anyone
with repo access to forge session tokens. `TrustServerCertificate=true` additionally
@@ -237,16 +237,16 @@ environment.
Resolved 2026-05-16 (commit `pending`). Root cause confirmed against
`appsettings.Central.json`: the committed file carried real-looking secrets in
plaintext — SQL Server passwords (`Password=ScadaLink_Dev1#`) in both connection
plaintext — SQL Server passwords (`Password=ScadaBridge_Dev1#`) in both connection
strings, an LDAP service-account password, and a JWT signing key. Fix: all four
secrets were removed from the committed file and replaced with non-functional
`${...}` placeholder references (`ConfigurationDb` / `MachineDataDb`,
`LdapServiceAccountPassword`, `JwtSigningKey`). A new top-level `_secrets` note
documents that the Host's configuration builder (`AddEnvironmentVariables()`)
overlays the real values supplied via environment variables
(`ScadaLink__Database__ConfigurationDb`, `ScadaLink__Database__MachineDataDb`,
`ScadaLink__Security__LdapServiceAccountPassword`,
`ScadaLink__Security__JwtSigningKey`); the placeholders are intentionally invalid so
(`ScadaBridge__Database__ConfigurationDb`, `ScadaBridge__Database__MachineDataDb`,
`ScadaBridge__Security__LdapServiceAccountPassword`,
`ScadaBridge__Security__JwtSigningKey`); the placeholders are intentionally invalid so
a misconfigured deployment fails loudly rather than silently using a committed key.
Regression test class `ConfigSecretsTests` asserts the committed file carries no
plaintext `Password=` value, no committed LDAP service-account password, and no
@@ -266,13 +266,13 @@ scope; they should receive the same treatment in a follow-up.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Host/appsettings.Site.json:10-19` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json:10-19` |
**Description**
The shipped site config sets `Node:RemotingPort = 8082` and `Node:GrpcPort = 8083`,
but `Cluster:SeedNodes` is `["akka.tcp://scadalink@localhost:8082",
"akka.tcp://scadalink@localhost:8083"]`. The second seed node targets `8083`, which
but `Cluster:SeedNodes` is `["akka.tcp://scadabridge@localhost:8082",
"akka.tcp://scadabridge@localhost:8083"]`. The second seed node targets `8083`, which
is the Kestrel HTTP/2 gRPC port — not an Akka remoting endpoint. A node attempting to
join via that seed will try to establish an Akka.Remote TCP association against the
gRPC listener and fail. `StartupValidator` only checks that ≥2 seed nodes exist
@@ -290,7 +290,7 @@ to reject a seed node whose port equals this node's `GrpcPort`.
Resolved 2026-05-16 (commit `pending`). Root cause confirmed against
`appsettings.Site.json`: with `Node:RemotingPort = 8082` and `Node:GrpcPort = 8083`,
the second `Cluster:SeedNodes` entry was `akka.tcp://scadalink@localhost:8083` — the
the second `Cluster:SeedNodes` entry was `akka.tcp://scadabridge@localhost:8083` — the
Kestrel HTTP/2 gRPC port, not an Akka.Remote endpoint. `StartupValidator` only
checked seed-node *count* (≥2), so the misconfiguration passed silently. Fix, two
parts: (1) the shipped `appsettings.Site.json` second seed entry was corrected to a
@@ -313,7 +313,7 @@ appropriately before the fix and pass after.
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:345` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:345` |
**Description**
@@ -354,7 +354,7 @@ async startup pipeline and remain green (175 passed).
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:70-108` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:70-108` |
**Description**
@@ -404,7 +404,7 @@ suite green (175 passed).
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Host/StartupValidator.cs:43-47` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs:43-47` |
**Description**
@@ -421,7 +421,7 @@ piece.
Add a check in the `role == "Site"` block: if `GrpcPort` (resolved, including the
8083 default) equals `RemotingPort`, add an error
`"ScadaLink:Node:GrpcPort must differ from RemotingPort"`.
`"ScadaBridge:Node:GrpcPort must differ from RemotingPort"`.
**Resolution**
@@ -429,7 +429,7 @@ Resolved 2026-05-16 (commit `pending`). Root cause confirmed against
`StartupValidator.cs`: the `role == "Site"` block validated the GrpcPort range but
never compared it to `RemotingPort`, so a site config setting both ports equal
passed validation and then failed opaquely when Kestrel and Akka.Remote both bound
the port. Fix: added `if (port == grpcPort) errors.Add("ScadaLink:Node:GrpcPort must
the port. Fix: added `if (port == grpcPort) errors.Add("ScadaBridge:Node:GrpcPort must
differ from RemotingPort")` in the Site block, using the resolved GrpcPort (including
the 8083 `NodeOptions` default when the key is absent) against the parsed
RemotingPort. Regression tests in `StartupValidatorTests`:
@@ -445,11 +445,11 @@ before the fix and pass after. Full Host suite green (175 passed).
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Host/StartupValidator.cs:33-34`, `src/ScadaLink.Host/DatabaseOptions.cs:6` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs:33-34`, `src/ZB.MOM.WW.ScadaBridge.Host/DatabaseOptions.cs:6` |
**Description**
`StartupValidator` requires a non-empty `ScadaLink:Database:MachineDataDb` connection
`StartupValidator` requires a non-empty `ScadaBridge:Database:MachineDataDb` connection
string for Central nodes, and `DatabaseOptions` exposes a `MachineDataDb` property,
but a repo-wide search shows the value is never read anywhere outside the Host module
— only `ConfigurationDb` is passed to `AddConfigurationDatabase`
@@ -489,7 +489,7 @@ key harmlessly — it will simply be ignored; a follow-up may remove it for tidi
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:127-141` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:127-141` |
**Description**
@@ -540,7 +540,7 @@ Host suite green (175 passed).
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Program.cs:112-125` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:112-125` |
**Description**
@@ -585,40 +585,40 @@ the pre-fix code (the helper did not exist) and pass after. Full Host suite gree
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Host/LoggingOptions.cs:5`, `src/ScadaLink.Host/Program.cs:42-50` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/LoggingOptions.cs:5`, `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:42-50` |
**Description**
`LoggingOptions` exposes a `MinimumLevel` property bound from `ScadaLink:Logging`
`LoggingOptions` exposes a `MinimumLevel` property bound from `ScadaBridge:Logging`
(`SiteServiceRegistration.BindSharedOptions`), and both `appsettings.Central.json`
and `appsettings.Site.json` set `"Logging": { "MinimumLevel": "Information" }`.
However Serilog is configured purely via `ReadFrom.Configuration(configuration)`,
which reads the standard `Serilog` section — not `ScadaLink:Logging`. The
which reads the standard `Serilog` section — not `ScadaBridge:Logging`. The
`LoggingOptions.MinimumLevel` value is never read by any code, so changing it has no
effect. This is misleading: an operator editing `ScadaLink:Logging:MinimumLevel`
effect. This is misleading: an operator editing `ScadaBridge:Logging:MinimumLevel`
expecting a log-level change will see nothing happen.
**Recommendation**
Either consume `LoggingOptions.MinimumLevel` when configuring the Serilog
`LoggerConfiguration` (e.g. set `MinimumLevel.Is(...)` from it), or remove the option
class and the `ScadaLink:Logging` sections and rely solely on the `Serilog`
class and the `ScadaBridge:Logging` sections and rely solely on the `Serilog`
configuration section. Keep one mechanism, not two.
**Resolution**
Resolved 2026-05-16 (commit `pending`). Root cause confirmed: `LoggingOptions.MinimumLevel`
was bound from `ScadaLink:Logging` but Serilog was configured purely via
was bound from `ScadaBridge:Logging` but Serilog was configured purely via
`ReadFrom.Configuration` (standard `Serilog` section), so editing
`ScadaLink:Logging:MinimumLevel` had no effect. Re-triaged the two options: the
`ScadaBridge:Logging:MinimumLevel` had no effect. Re-triaged the two options: the
"remove the option class" branch was rejected because `Component-Host.md` REQ-HOST-3
explicitly lists `ScadaLink:Logging``LoggingOptions` and that design doc is outside
explicitly lists `ScadaBridge:Logging``LoggingOptions` and that design doc is outside
this task's edit scope — removing the class would create a fresh code-vs-doc drift.
Fix (the "consume it" branch): added a `LoggerConfigurationFactory.Build` that binds
`LoggingOptions`, parses `MinimumLevel` into a Serilog `LogEventLevel` (falling back
to `Information` for null/blank/unrecognised values), and pins it via
`MinimumLevel.Is(...)` on top of `ReadFrom.Configuration`; `Program.cs` now builds the
logger through this factory. `ScadaLink:Logging:MinimumLevel` is now live.
logger through this factory. `ScadaBridge:Logging:MinimumLevel` is now live.
Regression tests in new `LoggerConfigurationTests`:
`MinimumLevel_Warning_SuppressesInformationLogs`,
`MinimumLevel_Debug_AllowsDebugLogs`, and `MinimumLevel_Absent_DefaultsToInformation`
@@ -633,17 +633,17 @@ key). Full Host suite green (175 passed).
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:146-148` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:146-148` |
**Description**
`AkkaHostedService.BuildHocon` emits the split-brain-resolver block with
`keep-oldest { down-if-alone = on }` as a literal constant. `ClusterOptions`
(`src/ScadaLink.ClusterInfrastructure/ClusterOptions.cs:74`) exposes a
`DownIfAlone` property — bound from `ScadaLink:Cluster` via the Options pattern,
(`src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs:74`) exposes a
`DownIfAlone` property — bound from `ScadaBridge:Cluster` via the Options pattern,
documented as "the design-doc requirement", default `true` — but a repo-wide search
shows it is referenced **nowhere outside its own declaration**. The Host therefore
ignores the bound value entirely: setting `ScadaLink:Cluster:DownIfAlone` to `false`
ignores the bound value entirely: setting `ScadaBridge:Cluster:DownIfAlone` to `false`
in `appsettings.json` has no effect, the resolver still runs with `down-if-alone =
on`. This is the same class of defect as the resolved Host-011
(`LoggingOptions.MinimumLevel` was dead config) — a configuration option that is
@@ -659,7 +659,7 @@ Make `BuildHocon` consume `clusterOptions.DownIfAlone` — emit `down-if-alone =
{(clusterOptions.DownIfAlone ? "on" : "off")}` (the value is a bool, so no escaping
is needed). Add a `HoconBuilderTests` case asserting both `true` and `false` produce
the corresponding `down-if-alone` token. If the flag is genuinely meant to be fixed
at `on` for ScadaLink's two-node clusters, remove the `DownIfAlone` property and its
at `on` for ScadaBridge's two-node clusters, remove the `DownIfAlone` property and its
doc comment instead so code and configuration contract agree — but do not leave it
declared-but-dead.
@@ -682,7 +682,7 @@ the fix (token was still `on`) and passes after. Full Host suite green (182 pass
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:135-136,145,151-152` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:135-136,145,151-152` |
**Description**
@@ -727,7 +727,7 @@ after. Full Host suite green (182 passed).
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Program.cs:43-48`, `src/ScadaLink.Host/appsettings.json:1-7` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:43-48`, `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json:1-7` |
**Description**
@@ -738,7 +738,7 @@ configuration section — but neither `appsettings.json` nor either role-specifi
contains a `Serilog` section (only a Microsoft `Logging` section with a `LogLevel`
map, which Serilog's `ReadFrom.Configuration` does not consume). The two sinks that
actually run are appended in `Program.cs` as hard-coded `.WriteTo.Console(...)` and
`.WriteTo.File("logs/scadalink-.log", rollingInterval: Day)` calls. As a result the
`.WriteTo.File("logs/scadabridge-.log", rollingInterval: Day)` calls. As a result the
console output template, the file path, and the rolling interval cannot be changed
without recompiling — an operator cannot redirect logs, change the file location, or
add a sink via configuration, contrary to REQ-HOST-8. The `ReadFrom.Configuration`
@@ -761,7 +761,7 @@ role-specific file contained a `Serilog` section, so the two sinks that actually
were hard-coded `.WriteTo.Console(...)` / `.WriteTo.File(...)` calls in `Program.cs`
— uneditable without recompiling, contrary to REQ-HOST-8. Fix: added a `Serilog`
section to `appsettings.json` with `WriteTo` entries for the Console and File sinks
(carrying the output template, file path `logs/scadalink-.log` and `Day` rolling
(carrying the output template, file path `logs/scadabridge-.log` and `Day` rolling
interval as args) and removed the hard-coded `.WriteTo` calls from `Program.cs`; the
factory's `ReadFrom.Configuration` (backed by the transitive
`Serilog.Settings.Configuration` package) now drives the sinks. Regression tests in
@@ -779,7 +779,7 @@ green (182 passed).
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Host/StartupRetry.cs:36-45` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/StartupRetry.cs:36-45` |
**Description**
@@ -832,13 +832,13 @@ Full Host suite green (182 passed).
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Host/appsettings.Site.json:33-37` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json:33-37` |
**Description**
The shipped site config sets `Node:RemotingPort = 8082` and lists
`Communication:CentralContactPoints` as
`["akka.tcp://scadalink@localhost:8081", "akka.tcp://scadalink@localhost:8082"]`.
`["akka.tcp://scadabridge@localhost:8081", "akka.tcp://scadabridge@localhost:8082"]`.
The second contact point — port `8082` — is the **site's own** remoting endpoint,
not a central node. `SiteCommunicationActor` / `ClusterClient` uses these
addresses as initial contacts when discovering the central
@@ -861,7 +861,7 @@ multi-node layout). Consider extending `StartupValidator` to reject any
node's `NodeHostname`+`RemotingPort`. Add a regression test in
`StartupValidatorTests` mirroring `Site_SeedNodeOnGrpcPort_FailsValidation`.
**Resolution (2026-05-28):** The shipped `appsettings.Site.json` `CentralContactPoints` entry that pointed at the site's own remoting port (`localhost:8082`) was removed — the dev-loopback default now lists only the single central node (`akka.tcp://scadalink@localhost:8081`), which is the actually-reachable target in the single-node dev layout. A `_centralContactPoints` doc-key comment was added immediately above the array calling out the per-entry rule (each entry MUST be a central node's remoting endpoint, not the site's own remoting port) and explaining how to extend the list with a second central node (`akka.tcp://scadalink@central-b-host:8081`) in a multi-central deployment so ClusterClient can fail over. The dangerous example pattern that would have been copied into multi-central configs no longer exists in the template. `StartupValidator` cross-check is left as a follow-up — the documented rule plus the corrected template removes the immediate misconfiguration risk.
**Resolution (2026-05-28):** The shipped `appsettings.Site.json` `CentralContactPoints` entry that pointed at the site's own remoting port (`localhost:8082`) was removed — the dev-loopback default now lists only the single central node (`akka.tcp://scadabridge@localhost:8081`), which is the actually-reachable target in the single-node dev layout. A `_centralContactPoints` doc-key comment was added immediately above the array calling out the per-entry rule (each entry MUST be a central node's remoting endpoint, not the site's own remoting port) and explaining how to extend the list with a second central node (`akka.tcp://scadabridge@central-b-host:8081`) in a multi-central deployment so ClusterClient can fail over. The dangerous example pattern that would have been copied into multi-central configs no longer exists in the template. `StartupValidator` cross-check is left as a follow-up — the documented rule plus the corrected template removes the immediate misconfiguration risk.
### Host-017 — Site-shutdown ordering from REQ-HOST-7 is not wired
@@ -870,7 +870,7 @@ node's `NodeHostname`+`RemotingPort`. Add a regression test in
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Program.cs:229-265`, `src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:229-265`, `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcServer.cs` |
**Description**
@@ -904,7 +904,7 @@ before the Akka hosted service runs `CoordinatedShutdown` (or order via
reverse-registration order, so the gRPC server's lifetime can be sequenced
before Akka shutdown). Alternatively, reconcile REQ-HOST-7 with the actual
implementation if the explicit ordering is no longer intended. Add an
integration test under `tests/ScadaLink.Host.Tests` that starts a site host,
integration test under `tests/ZB.MOM.WW.ScadaBridge.Host.Tests` that starts a site host,
opens a stream, triggers shutdown, and asserts the stream completes with
`Cancelled` before the actor system tears down.
@@ -928,7 +928,7 @@ ordering. Clients observe a clean `Cancelled` and reconnect rather than a
silent stream that times out via keepalive (~25 s).
Two unit regression tests added to
`tests/ScadaLink.Communication.Tests/Grpc/SiteStreamGrpcServerTests.cs`:
`tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/SiteStreamGrpcServerTests.cs`:
`Host017_CancelAllStreams_CancelsActiveStreamsAndRefusesNewOnes` (active
streams complete, new ones rejected) and `Host017_CancelAllStreams_IsIdempotent`
(double-call safe). A full site-host integration test was deferred — the
@@ -942,7 +942,7 @@ unit suite covers both server-side invariants and the wiring is a single
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Host/appsettings.Central.json`, `src/ScadaLink.Host/appsettings.Site.json`, `src/ScadaLink.Host/NodeOptions.cs:10-16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Central.json`, `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.Site.json`, `src/ZB.MOM.WW.ScadaBridge.Host/NodeOptions.cs:10-16` |
**Description**
@@ -955,8 +955,8 @@ telemetry and reconciliation, and is indexed via
`IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)`. The docker per-node
configs (`docker/central-node-a/appsettings.Central.json`,
`docker/site-a-node-a/appsettings.Site.json`, etc.) all set
`ScadaLink:Node:NodeName`. The **shipped, default** per-role files in
`src/ScadaLink.Host/` — the templates a developer running the binary
`ScadaBridge:Node:NodeName`. The **shipped, default** per-role files in
`src/ZB.MOM.WW.ScadaBridge.Host/` — the templates a developer running the binary
directly will use — do not. `NodeIdentityProvider` normalises an empty
`NodeName` to `null`, so dev audit rows carry a null `SourceNode` and the
indexed lookup never narrows. The dev examples should match the docker
@@ -972,7 +972,7 @@ per-node in multi-node deployments. Consider validating in `StartupValidator`
that `NodeName` is non-empty, or accept the null and document explicitly that
single-node dev deployments leave `SourceNode` null.
**Resolution (2026-05-28):** The shipped per-role templates now set `ScadaLink:Node:NodeName``central-a` in `appsettings.Central.json` and `node-a` in `appsettings.Site.json` — so dev audit rows are stamped with a real `SourceNode` value (instead of `NodeIdentityProvider` normalising the missing key to `null`) and the indexed `IX_AuditLog_Node_Occurred` lookup actually narrows. A `_nodeName` doc-key comment was added beside each `Node` section explaining the convention (`central-a`/`central-b` for central, `node-a`/`node-b` for site), pointing at the docker per-node configs (which already overrode the field), and noting that the value must be overridden per-node in multi-node deployments and that an empty value still normalises to a `NULL` SourceNode. The shipped dev templates now match the per-node docker examples — a developer running the binary directly no longer sees a null `SourceNode`.
**Resolution (2026-05-28):** The shipped per-role templates now set `ScadaBridge:Node:NodeName``central-a` in `appsettings.Central.json` and `node-a` in `appsettings.Site.json` — so dev audit rows are stamped with a real `SourceNode` value (instead of `NodeIdentityProvider` normalising the missing key to `null`) and the indexed `IX_AuditLog_Node_Occurred` lookup actually narrows. A `_nodeName` doc-key comment was added beside each `Node` section explaining the convention (`central-a`/`central-b` for central, `node-a`/`node-b` for site), pointing at the docker per-node configs (which already overrode the field), and noting that the value must be overridden per-node in multi-node deployments and that an empty value still normalises to a `NULL` SourceNode. The shipped dev templates now match the per-node docker examples — a developer running the binary directly no longer sees a null `SourceNode`.
### Host-019 — Migration `StartupRetry` call drops the host `CancellationToken`
@@ -981,7 +981,7 @@ single-node dev deployments leave `SourceNode` null.
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Program.cs:154-165` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:154-165` |
**Resolution (2026-05-28):** Added a `Func<CancellationToken, Task>` overload of `StartupRetry.ExecuteWithRetryAsync` that forwards the retry-loop token into the operation, and the migration call site in `Program.cs` now passes `app.Lifetime.ApplicationStopping` as both the operation token (threaded to `MigrationHelper.ApplyOrValidateMigrationsAsync`) and the loop's `cancellationToken` (already honoured by the inter-attempt `Task.Delay`). A SIGTERM during the bounded retry window now tears down cleanly instead of waiting up to ~2 minutes for the loop to exhaust. The original `Func<Task>` overload still exists and delegates, so existing callers/tests are unchanged.
@@ -1018,7 +1018,7 @@ _Open._
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.Host/LoggerConfigurationFactory.cs:36-43` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/LoggerConfigurationFactory.cs:36-43` |
**Description**
@@ -1026,10 +1026,10 @@ _Open._
via `ReadFrom.Configuration(configuration)` (which can include a
`MinimumLevel` block — the standard Serilog way to set the floor) and **then**
calls `.MinimumLevel.Is(minimumLevel)` derived from
`ScadaLink:Logging:MinimumLevel`. Serilog's fluent builder applies the later
`ScadaBridge:Logging:MinimumLevel`. Serilog's fluent builder applies the later
call, so any `Serilog:MinimumLevel:Default` an operator sets is silently
overridden by `ScadaLink:Logging:MinimumLevel` (or by its
`Information` fallback when the ScadaLink key is absent). There are now two
overridden by `ScadaBridge:Logging:MinimumLevel` (or by its
`Information` fallback when the ScadaBridge key is absent). There are now two
documented configuration paths for the same setting with non-obvious
precedence, and the override direction is the opposite of what most Serilog
users would expect (the more-specific `Serilog` section being the authority).
@@ -1041,12 +1041,12 @@ but does not warn that the floor *overrides* the Serilog section's own
Pick one mechanism: either (a) drop the `MinimumLevel.Is` call and let
`ReadFrom.Configuration` consume `Serilog:MinimumLevel`, migrating any docs/
deployments that reference `ScadaLink:Logging:MinimumLevel`; or (b) keep the
current "ScadaLink:Logging" path and reject `Serilog:MinimumLevel` if present
deployments that reference `ScadaBridge:Logging:MinimumLevel`; or (b) keep the
current "ScadaBridge:Logging" path and reject `Serilog:MinimumLevel` if present
(throw at startup so the operator sees the conflict). At minimum, expand the
XML doc + REQ-HOST-8 to spell out the precedence explicitly.
**Resolution (2026-05-28):** `ScadaLink:Logging:MinimumLevel` is now the documented single source of truth for the Serilog floor (Host-011's `LoggingOptions` binding), and the precedence is made visible — `LoggerConfigurationFactory.Build` writes a one-shot warning to `Console.Error` when both `ScadaLink:Logging:MinimumLevel` and `Serilog:MinimumLevel` (or `Serilog:MinimumLevel:Default`) are present, naming both values and pointing the operator at the documented key. Order of operations is unchanged — `MinimumLevel.Is(...)` deliberately runs after `ReadFrom.Configuration(...)` so the ScadaLink value wins — but the silent-override behaviour is now loud. The class XML doc gained a Host-020 paragraph explicitly spelling out the precedence. A test-visible `Build(..., TextWriter warningWriter)` overload mirrors the `ParseLevel` Host-022 pattern so the warning can be asserted in unit tests; the production four-arg overload delegates with `Console.Error`.
**Resolution (2026-05-28):** `ScadaBridge:Logging:MinimumLevel` is now the documented single source of truth for the Serilog floor (Host-011's `LoggingOptions` binding), and the precedence is made visible — `LoggerConfigurationFactory.Build` writes a one-shot warning to `Console.Error` when both `ScadaBridge:Logging:MinimumLevel` and `Serilog:MinimumLevel` (or `Serilog:MinimumLevel:Default`) are present, naming both values and pointing the operator at the documented key. Order of operations is unchanged — `MinimumLevel.Is(...)` deliberately runs after `ReadFrom.Configuration(...)` so the ScadaBridge value wins — but the silent-override behaviour is now loud. The class XML doc gained a Host-020 paragraph explicitly spelling out the precedence. A test-visible `Build(..., TextWriter warningWriter)` overload mirrors the `ParseLevel` Host-022 pattern so the warning can be asserted in unit tests; the production four-arg overload delegates with `Console.Error`.
### Host-021 — Microsoft `Logging:LogLevel` section in `appsettings.json` is dead config under Serilog
@@ -1055,7 +1055,7 @@ XML doc + REQ-HOST-8 to spell out the precedence explicitly.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Host/appsettings.json:2-6` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json:2-6` |
**Description**
@@ -1075,10 +1075,10 @@ the .NET convention) sees no behaviour change — the section is dead config.
Either remove the `Logging:LogLevel` block from `appsettings.json` (Serilog
owns logging configuration in this Host), or replace it with a brief comment
explaining it is intentionally retained for non-Serilog tooling. Document the
authoritative location (`Serilog` + `ScadaLink:Logging`) in
authoritative location (`Serilog` + `ScadaBridge:Logging`) in
`Component-Host.md` REQ-HOST-8 if not already explicit.
**Resolution (2026-05-28):** Confirmed by repository-wide grep that no code reads `Logging:LogLevel` (the Host calls `builder.Host.UseSerilog()` which replaces the default `ILoggerFactory` setup with Serilog as the only provider), so the block was pure dead config. Removed the `Logging:LogLevel:Default = Information` block from `appsettings.json` and replaced it with a `_logging` doc-key comment explaining the rationale (Serilog is the sole provider) and pointing operators at the two authoritative keys: `ScadaLink:Logging:MinimumLevel` for the floor (bound to `LoggingOptions` per Host-011) and the `Serilog` section for sinks (Host-014's `ReadFrom.Configuration`). The Host-014 regression test (`SerilogSinkConfigTests.ShippedAppSettings_HasSerilogSection_WithConsoleAndFileSinks`) still asserts the surviving `Serilog` section's shape, so removing the Microsoft block did not break the existing pinning.
**Resolution (2026-05-28):** Confirmed by repository-wide grep that no code reads `Logging:LogLevel` (the Host calls `builder.Host.UseSerilog()` which replaces the default `ILoggerFactory` setup with Serilog as the only provider), so the block was pure dead config. Removed the `Logging:LogLevel:Default = Information` block from `appsettings.json` and replaced it with a `_logging` doc-key comment explaining the rationale (Serilog is the sole provider) and pointing operators at the two authoritative keys: `ScadaBridge:Logging:MinimumLevel` for the floor (bound to `LoggingOptions` per Host-011) and the `Serilog` section for sinks (Host-014's `ReadFrom.Configuration`). The Host-014 regression test (`SerilogSinkConfigTests.ShippedAppSettings_HasSerilogSection_WithConsoleAndFileSinks`) still asserts the surviving `Serilog` section's shape, so removing the Microsoft block did not break the existing pinning.
### Host-022 — `ParseLevel` silently coerces unrecognised `MinimumLevel` to `Information`
@@ -1087,7 +1087,7 @@ authoritative location (`Serilog` + `ScadaLink:Logging`) in
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Host/LoggerConfigurationFactory.cs:50-55` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/LoggerConfigurationFactory.cs:50-55` |
**Resolution (2026-05-28):** `ParseLevel` now writes a one-shot warning to `Console.Error` (the logger isn't built yet at this point) when a non-null/non-blank `MinimumLevel` value fails to parse, naming the offending value and the `Information` fallback. Null/blank values continue to default silently (treated as "unset"). The helper gained a test-visible `TextWriter` overload so unit tests can capture the warning; the production path delegates to it with `Console.Error`. Tests `ParseLevel_UnrecognisedValue_FallsBackAndWarns`, `ParseLevel_NullOrBlank_FallsBackSilently`, and `ParseLevel_RecognisedValue_NoWarning` pin the behaviour.
@@ -1097,7 +1097,7 @@ authoritative location (`Serilog` + `ScadaLink:Logging`) in
`Enum.TryParse<LogEventLevel>(level, ignoreCase: true, out var parsed)` and
returns `LogEventLevel.Information` when parsing fails — without logging the
fallback. An operator who sets
`ScadaLink:Logging:MinimumLevel = "Informaiton"` (a common typo) or
`ScadaBridge:Logging:MinimumLevel = "Informaiton"` (a common typo) or
`"Verbose,Debug"` or any unrecognised value gets the default level silently;
there is no warning, no log line, no startup error. Combined with Host-020
(this is the only mechanism that pins the floor), a misspelt value is
@@ -1111,7 +1111,7 @@ In `LoggerConfigurationFactory.Build`, when `loggingOptions.MinimumLevel` is
non-null/non-blank but does not parse to a valid `LogEventLevel`, write a
`Console.Error.WriteLine` warning (the logger is not yet built) and proceed
with `Information`. Alternatively, validate the value in `StartupValidator`
and fail fast — that matches the pattern used for other ScadaLink
and fail fast — that matches the pattern used for other ScadaBridge
configuration keys. Add a `LoggerConfigurationTests` case asserting the
behaviour you choose.
+51 -51
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.InboundAPI` |
| Module | `src/ZB.MOM.WW.ScadaBridge.InboundAPI` |
| Design doc | `docs/requirements/Component-InboundAPI.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -33,7 +33,7 @@ are High severity and should be addressed before production use.
#### Re-review 2026-05-17 (commit `39d737e`)
All 13 findings from the initial review remain `Resolved`; the module source under
`src/ScadaLink.InboundAPI` is unchanged since the last InboundAPI fix commit
`src/ZB.MOM.WW.ScadaBridge.InboundAPI` is unchanged since the last InboundAPI fix commit
(`8dd7412`), which precedes `39d737e`. This re-review re-walked all 10 checklist
categories against the resolved code and surfaced **4 new findings** — none touching
the previously-fixed concurrency/trust-model code, but all in areas the first pass
@@ -68,16 +68,16 @@ statement that the timeout covers routed calls (InboundAPI-016); and (4) `RouteH
All 17 prior findings remain `Resolved`. The module has grown materially since the
last pass — a new `AuditWriteMiddleware` (Audit Log #23 M4 Bundle D) now lives under
`src/ScadaLink.InboundAPI/Middleware/`, the `ApiKeyValidator` was rewired to hash the
`src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/`, the `ApiKeyValidator` was rewired to hash the
candidate with `IApiKeyHasher` (ConfigurationDatabase-012), and an `IInstanceRouter`
seam was introduced. This re-review re-walked all 10 checklist categories against
`1eb6e97` and surfaced **8 new findings** concentrated on the new audit middleware
and a stranded follow-up from InboundAPI-008:
1. The InboundAPI-008 resolution explicitly deferred registering an `IActiveNodeGate`
implementation in `ScadaLink.Host` as a "follow-up outside this module's scope" —
implementation in `ZB.MOM.WW.ScadaBridge.Host` as a "follow-up outside this module's scope" —
that follow-up is still unfulfilled (no production registration anywhere in
`src/ScadaLink.Host/`), so the design-mandated standby-node gating is silently
`src/ZB.MOM.WW.ScadaBridge.Host/`), so the design-mandated standby-node gating is silently
disabled in production today (`InboundAPI-022`, High).
2. `AuditWriteMiddleware` is wired in `Program.cs` against `/api/*` rather than the
specific `POST /api/{methodName}` route, so GETs against `/api/audit/query` and
@@ -133,7 +133,7 @@ configuration database, but the invariant is undocumented.)
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:17`, `:32`, `:40`, `:89`, `:123-128` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:17`, `:32`, `:40`, `:89`, `:123-128` |
**Description**
@@ -169,7 +169,7 @@ via `GetOrAdd` so concurrent first-callers share one handler. Regression tests
| Severity | Medium — re-triaged: already fixed by the InboundAPI-001 fix; verified and closed |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:152-161` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:152-161` |
**Description**
@@ -209,7 +209,7 @@ handler that `GetOrAdd` keeps. Regression test
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ConfigurationDatabase/Repositories/InboundApiRepository.cs:22-23`, consumed by `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:33` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/InboundApiRepository.cs:22-23`, consumed by `src/ZB.MOM.WW.ScadaBridge.InboundAPI/ApiKeyValidator.cs:33` |
**Description**
@@ -251,7 +251,7 @@ longer depends on it.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:117-141` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:117-141` |
**Description**
@@ -293,7 +293,7 @@ added.
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:56-93` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:56-93` |
**Description**
@@ -337,7 +337,7 @@ Regression tests `CompileAndRegister_ForbiddenApi_RejectsScript` (theory),
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:54-62` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs:54-62` |
**Description**
@@ -365,7 +365,7 @@ It rejects requests whose declared `Content-Length` exceeds `InboundApiOptions.
MaxRequestBodyBytes` (default 1 MiB) with HTTP 413 *before* the handler buffers the
body into a `JsonDocument`, and also lowers the per-request `IHttpMaxRequestBodySizeFeature`
cap so a chunked/unknown-length stream is cut off by Kestrel while being read. The
limit is configurable via the bound `ScadaLink:InboundApi` options section. Regression
limit is configurable via the bound `ScadaBridge:InboundApi` options section. Regression
tests `OversizedBody_ShortCircuitsWith413_AndDoesNotRunHandler`, `BodyAtLimit_RunsHandler`,
and `FilterCapsMaxRequestBodySizeFeature` added.
@@ -376,7 +376,7 @@ and `FilterCapsMaxRequestBodySizeFeature` added.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:188-203` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:188-203` |
**Description**
@@ -402,7 +402,7 @@ Resolved 2026-05-16 (commit `<pending>`). The drift was confirmed real:
so a method script following the documented `Database.Connection("name")` API
would fail to compile. Resolution direction: the design doc is stale, not the
code. Implementing `Database.Connection()` would hand inbound API scripts a
*raw* MS SQL client, in direct tension with the ScadaLink script trust model
*raw* MS SQL client, in direct tension with the ScadaBridge script trust model
(scripts are forbidden `System.IO`, raw network, etc.; `ForbiddenApiChecker`
statically enforces this). Rather than carve a hole in the trust model, the
"Database Access" section was removed from `docs/requirements/Component-InboundAPI.md`
@@ -418,7 +418,7 @@ explicit design change. Code and doc now agree; no code or test change required.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:19-23`, `src/ScadaLink.Host/Program.cs:149` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs:19-23`, `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:149` |
**Description**
@@ -447,7 +447,7 @@ auth/script work, so Traefik/clients only reach the live node — consistent wit
registered (non-clustered host / tests) the endpoint defaults to "allow", preserving
prior behaviour. Regression tests `StandbyNode_ShortCircuitsWith503_AndDoesNotRunHandler`,
`ActiveNode_PassesGate_RunsHandler`, and `NoGateRegistered_PassesGate_RunsHandler`
added. **Follow-up (outside this module's scope):** `ScadaLink.Host` should register
added. **Follow-up (outside this module's scope):** `ZB.MOM.WW.ScadaBridge.Host` should register
an `IActiveNodeGate` implementation backed by `ActiveNodeHealthCheck` /
`Cluster.State.Leader` in the central-role branch of `Program.cs` so the gate is
actually enforced in production; until then the endpoint defaults to "allow".
@@ -459,7 +459,7 @@ actually enforced in production; until then the endpoint defaults to "allow".
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:123-128` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:123-128` |
**Description**
@@ -496,7 +496,7 @@ re-evaluated. Regression tests `FailedCompilation_IsNotRetriedOnEveryRequest`
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/ParameterValidator.cs:64-90`, `:112-118` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs:64-90`, `:112-118` |
**Description**
@@ -541,7 +541,7 @@ follow-up. Regression tests `UnexpectedBodyField_ReturnsInvalid` and
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:39-52` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/ApiKeyValidator.cs:39-52` |
**Description**
@@ -580,14 +580,14 @@ indistinguishable contract.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/ParameterValidator.cs:128-133` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/ParameterValidator.cs:128-133` |
**Description**
`ParameterDefinition` is a persistence-/contract-shaped POCO: it is the deserialized
form of `ApiMethod.ParameterDefinitions` (a column in the configuration database) and
describes the public API contract. CLAUDE.md's code-organization rules place
persistence-ignorant entity/contract types in `ScadaLink.Commons`. Defining it inside
persistence-ignorant entity/contract types in `ZB.MOM.WW.ScadaBridge.Commons`. Defining it inside
the InboundAPI project means any other component that needs to read or produce method
parameter definitions (e.g. Central UI's method editor, CLI, Management Service)
cannot share the type and will duplicate it.
@@ -595,7 +595,7 @@ cannot share the type and will duplicate it.
**Recommendation**
Move `ParameterDefinition` (and a matching return-definition type, if added) to
`ScadaLink.Commons` under the InboundApi entity/types namespace so it is shared by all
`ZB.MOM.WW.ScadaBridge.Commons` under the InboundApi entity/types namespace so it is shared by all
components that work with method definitions.
**Resolution**
@@ -604,22 +604,22 @@ Resolved 2026-05-16 (commit `<pending>`): root cause confirmed against the sourc
`ParameterDefinition` was a persistence-ignorant, API-contract-shaped POCO (the
deserialized form of the `ApiMethod.ParameterDefinitions` configuration-database
column) declared inside the component project, contrary to CLAUDE.md's
code-organization rule that such shared contract types live in `ScadaLink.Commons`.
The type was moved to `src/ScadaLink.Commons/Types/InboundApi/ParameterDefinition.cs`
(namespace `ScadaLink.Commons.Types.InboundApi`) — placed under `Types/` with an
code-organization rule that such shared contract types live in `ZB.MOM.WW.ScadaBridge.Commons`.
The type was moved to `src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/ParameterDefinition.cs`
(namespace `ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi`) — placed under `Types/` with an
`InboundApi` domain subfolder, matching the existing `Types/Scripts/` precedent, since
the column itself is the persisted form and this type is its deserialized contract
shape (not an EF-mapped entity). It remains a pure POCO with no EF attributes and no
behaviour. `ParameterValidator` now imports the moved type via a `using
ScadaLink.Commons.Types.InboundApi;` directive; a tree-wide search confirmed
ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;` directive; a tree-wide search confirmed
`ParameterValidator.cs` was the type's only declaration and only direct consumer (all
other `ParameterDefinition*` matches are the unrelated `ParameterDefinitions` string
property). No return-definition type exists in the codebase — only a `ReturnDefinition`
string column — so none was invented. No behavioural change, so no new runtime
regression test: this is a compile-level type move, and the existing 52
`ScadaLink.InboundAPI.Tests` (including the `ParameterValidator` suite) act as the
regression guard. `dotnet test` for `ScadaLink.InboundAPI.Tests` (52 passed) and
`ScadaLink.Commons.Tests` (226 passed) are green; `dotnet build ScadaLink.slnx`
`ZB.MOM.WW.ScadaBridge.InboundAPI.Tests` (including the `ParameterValidator` suite) act as the
regression guard. `dotnet test` for `ZB.MOM.WW.ScadaBridge.InboundAPI.Tests` (52 passed) and
`ZB.MOM.WW.ScadaBridge.Commons.Tests` (226 passed) are green; `dotnet build ZB.MOM.WW.ScadaBridge.slnx`
succeeds with 0 warnings / 0 errors.
### InboundAPI-013 — `ApiKeyValidationResult.NotFound` factory returns HTTP 400, contradicting its name
@@ -629,7 +629,7 @@ succeeds with 0 warnings / 0 errors.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/ApiKeyValidator.cs:78-79` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/ApiKeyValidator.cs:78-79` |
**Description**
@@ -667,7 +667,7 @@ from "key not approved"), but that doc edit is outside this module's editable sc
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:201-205`, `src/ScadaLink.Commons/Entities/InboundApi/ApiMethod.cs:10` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:201-205`, `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/InboundApi/ApiMethod.cs:10` |
**Description**
@@ -725,7 +725,7 @@ is validation-only (no coercion). Regression tests: `ReturnValueValidatorTests`
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/ForbiddenApiChecker.cs:63-119`, `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:109-126` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/ForbiddenApiChecker.cs:63-119`, `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:109-126` |
**Description**
@@ -799,7 +799,7 @@ namespace-deny-list regression guards.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/RouteHelper.cs:59-152`, `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:177`, `:199` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs:59-152`, `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:177`, `:199` |
**Description**
@@ -863,13 +863,13 @@ of running orphaned. Regression tests (in the new `RouteHelperTests`):
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/RouteHelper.cs:1-165`, `tests/ScadaLink.InboundAPI.Tests/` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs:1-165`, `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/` |
**Description**
`RouteHelper`/`RouteTarget` is the entire WP-4 cross-site routing surface — the
`Route.To().Call()/GetAttribute(s)/SetAttribute(s)` API that inbound API scripts use
to reach instances at any site. It has zero tests: the `ScadaLink.InboundAPI.Tests`
to reach instances at any site. It has zero tests: the `ZB.MOM.WW.ScadaBridge.InboundAPI.Tests`
project covers `ApiKeyValidator`, `ParameterValidator`, `InboundScriptExecutor`, and
`InboundApiEndpointFilter`, but no test file exercises `RouteHelper`. Untested
behaviours include site resolution via `IInstanceLocator` (including the
@@ -892,7 +892,7 @@ wiring is added.
**Resolution**
Resolved 2026-05-17 (commit `<pending>`): confirmed — `ScadaLink.InboundAPI.Tests` had
Resolved 2026-05-17 (commit `<pending>`): confirmed — `ZB.MOM.WW.ScadaBridge.InboundAPI.Tests` had
no file exercising `RouteHelper`/`RouteTarget`. To make the surface testable without a
live actor system, an `IInstanceRouter` seam was introduced in the module (the routing
transport `RouteHelper` depends on); the production `CommunicationServiceInstanceRouter`
@@ -912,7 +912,7 @@ the InboundAPI-016 deadline-token inheritance behaviour. All 15 pass.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs:257` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs:257` |
**Resolution (2026-05-28):** kept the fire-and-forget (audit emission must
never block or alter the user-facing response per alog.md §13) but added
@@ -968,7 +968,7 @@ synchronous throw) to pin the new contract.
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Location | `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs:141` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs:141` |
| Status | Resolved |
**Resolution (2026-05-28):** Added a `RequestHasBody(HttpRequest)` guard that returns `true` only when `ContentLength > 0` or the method is POST / PUT / PATCH (the `HttpMethods.IsPost/Put/Patch` helpers); the `EnableBuffering` + `ReadBufferedRequestBodyAsync` call now sits behind that guard. Bodyless GET / HEAD / DELETE / TRACE / OPTIONS requests (and any explicit `Content-Length: 0`) skip the `FileBufferingReadStream` allocation; body-carrying methods with no Content-Length (chunked POST etc.) still buffer. Regression tests `BodylessMethod_SkipsEnableBuffering_RequestStreamIsNotReplaced` (GET/HEAD/DELETE theory), `BodylessPost_ContentLengthZero_SkipsEnableBuffering`, and `PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary` (anti-regression).
@@ -1000,7 +1000,7 @@ bodyless request through the middleware.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:70` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs:70` |
**Resolution (2026-05-28):** swapped the case-sensitive `Contains("json")`
substring match for `Contains("json", StringComparison.OrdinalIgnoreCase)` so
@@ -1041,7 +1041,7 @@ regression test posting with `application/JSON` and Transfer-Encoding: chunked.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/RouteHelper.cs:141-143`, `:182-183`, `:225-226`; `src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs:15-21`, `:36-40`, `:55-59` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs:141-143`, `:182-183`, `:225-226`; `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs:15-21`, `:36-40`, `:55-59` |
**Description**
@@ -1104,9 +1104,9 @@ backlog), they can stamp the parent id without any further plumbing.
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/IActiveNodeGate.cs`, `src/ScadaLink.InboundAPI/InboundApiEndpointFilter.cs:52-60`; absent from `src/ScadaLink.Host/Program.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/IActiveNodeGate.cs`, `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiEndpointFilter.cs:52-60`; absent from `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` |
**Resolution** — Added `src/ScadaLink.Host/Health/ActiveNodeGate.cs`, a production `IActiveNodeGate` implementation backed by `AkkaHostedService` that mirrors `ActiveNodeHealthCheck`'s leadership probe (member status `Up` AND `Cluster.State.Leader == SelfAddress`), and registered it as a singleton in the central-role branch of `Program.cs`. A structural regression test (`CentralCompositionRootTests.Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate`) reflects over the built `IServiceProvider` to assert the registration's existence and concrete type — failing on `main` and passing after the fix. The `InboundApiEndpointFilter`'s fall-through-to-allow behaviour is retained as the documented safe default for non-clustered hosts and tests.
**Resolution** — Added `src/ZB.MOM.WW.ScadaBridge.Host/Health/ActiveNodeGate.cs`, a production `IActiveNodeGate` implementation backed by `AkkaHostedService` that mirrors `ActiveNodeHealthCheck`'s leadership probe (member status `Up` AND `Cluster.State.Leader == SelfAddress`), and registered it as a singleton in the central-role branch of `Program.cs`. A structural regression test (`CentralCompositionRootTests.Central_IActiveNodeGate_IsRegisteredAsActiveNodeGate`) reflects over the built `IServiceProvider` to assert the registration's existence and concrete type — failing on `main` and passing after the fix. The `InboundApiEndpointFilter`'s fall-through-to-allow behaviour is retained as the documented safe default for non-clustered hosts and tests.
**Description**
@@ -1118,14 +1118,14 @@ when `gate is { IsActiveNode: false }` returns HTTP 503. The filter's behaviour
when **no implementation is registered** (line 51 comment) is to fall through and
serve the request — the resolution paragraph for InboundAPI-008 closes with:
> "Follow-up (outside this module's scope): `ScadaLink.Host` should register an
> "Follow-up (outside this module's scope): `ZB.MOM.WW.ScadaBridge.Host` should register an
> `IActiveNodeGate` implementation backed by `ActiveNodeHealthCheck` /
> `Cluster.State.Leader` in the central-role branch of `Program.cs` so the gate is
> actually enforced in production; until then the endpoint defaults to "allow"."
A grep of the entire `src/ScadaLink.Host/` tree at `1eb6e97` finds **zero**
A grep of the entire `src/ZB.MOM.WW.ScadaBridge.Host/` tree at `1eb6e97` finds **zero**
`IActiveNodeGate` registrations: `grep -rn "IActiveNodeGate\|AddSingleton.*ActiveNode"
src/ScadaLink.Host/` returns no matches. The follow-up was never carried out. So
src/ZB.MOM.WW.ScadaBridge.Host/` returns no matches. The follow-up was never carried out. So
in production today the standby central node still serves the inbound API exactly
as InboundAPI-008 described — executes method scripts, runs `Route.To()` calls,
races the active node, and may operate against stale singleton state. The new
@@ -1138,7 +1138,7 @@ The design says the inbound API is "Central cluster only (active node)" and
**Recommendation**
Register an `IActiveNodeGate` implementation in the central-role branch of
`ScadaLink.Host/Program.cs`. The natural backing is the existing
`ZB.MOM.WW.ScadaBridge.Host/Program.cs`. The natural backing is the existing
`ActiveNodeHealthCheck` (already wired for `/health/active`) or a direct read of
`Cluster.Get(actorSystem).State.Leader == Cluster.Get(actorSystem).SelfAddress`.
Add an integration test in the Host that spins up the central role and asserts
@@ -1153,9 +1153,9 @@ realisation of the InboundAPI-008 vulnerability.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/EndpointExtensions.cs:31-140`, `tests/ScadaLink.InboundAPI.Tests/` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs:31-140`, `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/` |
**Resolution (2026-05-28):** Added `tests/ScadaLink.InboundAPI.Tests/EndpointExtensionsTests.cs`, a `TestServer`-hosted suite (same pattern as `EndpointContentTypeTests`) that drives the `POST /api/{methodName}` wiring end-to-end. Seven cases pin the composed flow: happy path (200 + script result body), missing API key (401), unknown method (403, indistinguishable from "not approved" per InboundAPI-011), invalid JSON body (400), missing required parameter (400 from `ParameterValidator`), script throws (500 with sanitized error body — the executor's catch-all replaces the raw exception with `"Internal script error"`), and the `HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]` actor-stash invariant (verified by an inline capture middleware reading the slot after the endpoint runs). All 7 new tests pass; total InboundAPI.Tests now 158 (was 151).
**Resolution (2026-05-28):** Added `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs`, a `TestServer`-hosted suite (same pattern as `EndpointContentTypeTests`) that drives the `POST /api/{methodName}` wiring end-to-end. Seven cases pin the composed flow: happy path (200 + script result body), missing API key (401), unknown method (403, indistinguishable from "not approved" per InboundAPI-011), invalid JSON body (400), missing required parameter (400 from `ParameterValidator`), script throws (500 with sanitized error body — the executor's catch-all replaces the raw exception with `"Internal script error"`), and the `HttpContext.Items[AuditWriteMiddleware.AuditActorItemKey]` actor-stash invariant (verified by an inline capture middleware reading the slot after the endpoint runs). All 7 new tests pass; total InboundAPI.Tests now 158 (was 151).
**Description**
@@ -1190,7 +1190,7 @@ resolved key name after successful auth, but is absent on auth failures).
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs:30`, `:77`, `:223`, `:233` |
| Location | `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:30`, `:77`, `:223`, `:233` |
**Resolution (2026-05-28):** capped the cache at `KnownBadMethodsCap = 1000`
entries via a new `TryRecordBadMethod` helper that short-circuits when the cap
@@ -1236,7 +1236,7 @@ immediate change required; this is a watch-list item.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Program.cs:183-185`; consumers: `src/ScadaLink.ManagementService/AuditEndpoints.cs:93-94`; emitter: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs:175-252` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:183-185`; consumers: `src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs:93-94`; emitter: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs:175-252` |
**Resolution (2026-05-28):** Took the defensive path-exclusion in
`Program.cs` (Option 1 from the recommendation). The `UseWhen` predicate
@@ -1255,7 +1255,7 @@ the excluded prefixes, which would be noisy in code review.
`Program.cs` wires the audit middleware as
`app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), branch => branch.UseAuditWriteMiddleware())`
— scoped to the `/api` *prefix*, not to the `POST /api/{methodName}` route.
Meanwhile, `ScadaLink.ManagementService/AuditEndpoints.cs` maps
Meanwhile, `ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs` maps
`MapGet("/api/audit/query", ...)` (line 93) and `MapGet("/api/audit/export", ...)`
(line 94). Both routes therefore inherit `AuditWriteMiddleware`, which emits an
`AuditEvent { Channel = AuditChannel.ApiInbound, Kind = AuditKind.InboundRequest, ... }`
+28 -28
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.ManagementService` |
| Module | `src/ZB.MOM.WW.ScadaBridge.ManagementService` |
| Design doc | `docs/requirements/Component-ManagementService.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -31,7 +31,7 @@ authorization bypass with no workaround.
#### Re-review 2026-05-17 (commit `39d737e`)
All thirteen prior findings remain correctly closed; the source under
`src/ScadaLink.ManagementService` is byte-identical between the previously reviewed state
`src/ZB.MOM.WW.ScadaBridge.ManagementService` is byte-identical between the previously reviewed state
and `39d737e` (the resolution commits of findings 001013 are folded into the history at or
before `39d737e`). ManagementService-012 was re-checked and its **Deferred** status still
holds: `ManagementEnvelope.Command` is still typed `object`, and the marker-interface fix
@@ -111,7 +111,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:1465`, `:1481`, `:1493`, `:641`, `:649` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:1465`, `:1481`, `:1493`, `:641`, `:649` |
**Description**
@@ -153,7 +153,7 @@ Resolved 2026-05-16 (commit `<pending>`). Threaded `AuthenticatedUser` into
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:510`, `:673`, `:733`, `:774`, `:631`, `:624` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:510`, `:673`, `:733`, `:774`, `:631`, `:624` |
**Description**
@@ -192,7 +192,7 @@ the resolved entity's site ID (instance `SiteId`, site `Id`, data-connection `Si
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/DebugStreamHub.cs:104` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs:104` |
**Description**
@@ -231,7 +231,7 @@ tests: `IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied`,
| Severity | Medium |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:61` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:61` |
**Description**
@@ -271,7 +271,7 @@ mapping tests confirm behaviour is preserved.
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:33` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:33` |
**Description**
@@ -306,7 +306,7 @@ children today, so this is a forward-looking correctness fix. Regression tests:
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementEndpoints.cs:83`, `:112` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs:83`, `:112` |
**Description**
@@ -342,7 +342,7 @@ string rather than parsing a throwaway document. Regression tests:
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:67`; `src/ScadaLink.ManagementService/ManagementEndpoints.cs:113` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:67`; `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs:113` |
**Description**
@@ -380,7 +380,7 @@ Regression tests: `SerializeResult_WithCyclicGraph_DoesNotThrow`,
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:285` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:285` |
**Description**
@@ -403,7 +403,7 @@ Resolved 2026-05-16 (commit pending). Confirmed: `HandleResolveRoles` did
(the two-step ResolveRoles flow is retired). Resolving 011 by deleting the
`ResolveRolesCommand` dispatch case and `HandleResolveRoles` handler also removes the only
manually-constructed `RoleMapper` in the module, so the DI-bypass no longer exists. No
remaining `new RoleMapper` in `src/ScadaLink.ManagementService`. Regression covered by
remaining `new RoleMapper` in `src/ZB.MOM.WW.ScadaBridge.ManagementService`. Regression covered by
`ResolveRolesCommand_IsNoLongerDispatched_ReturnsManagementError`.
### ManagementService-009 — Audit logging applied inconsistently across mutating handlers
@@ -413,7 +413,7 @@ remaining `new RoleMapper` in `src/ScadaLink.ManagementService`. Regression cove
| Severity | Low — re-triaged from Medium; the claimed audit gap does not exist (see Description), leaving only an undocumented-convention issue. |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:357`, `:1134`, `:1085`, `:526`, `:1275` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:357`, `:1134`, `:1085`, `:526`, `:1275` |
**Description**
@@ -461,7 +461,7 @@ covers the explicit-audit path.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementServiceOptions.cs:5`; `src/ScadaLink.ManagementService/ManagementEndpoints.cs:16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptions.cs:5`; `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs:16` |
**Description**
@@ -497,7 +497,7 @@ Regression tests: `ResolveAskTimeout_UsesConfiguredCommandTimeout`,
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:273`, `:283` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:273`, `:283` |
**Description**
@@ -526,7 +526,7 @@ falls through to the `NotSupportedException` default and gets a uniform `Managem
(closing the unauthenticated role-mapping enumeration surface, since `GetRequiredRole`
returned null for it). A code comment at the former dispatch site documents the intentional
omission. Note: the `ResolveRolesCommand` *record* itself lives in
`src/ScadaLink.Commons/Messages/Management/SecurityCommands.cs` and was left in place — that
`src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecurityCommands.cs` and was left in place — that
file is outside this module's permitted edit scope; deleting the orphan record should be done
as a Commons-module follow-up. With the handler removed it is now an inert,
registry-only type with no behaviour. Regression test:
@@ -539,7 +539,7 @@ registry-only type with no behaviour. Regression test:
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Deferred |
| Location | `src/ScadaLink.Commons/Messages/Management/ManagementEnvelope.cs:7`; `src/ScadaLink.ManagementService/ManagementActor.cs:132` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ManagementEnvelope.cs:7`; `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:132` |
**Description**
@@ -561,8 +561,8 @@ flag unhandled cases, and keeps `ManagementCommandRegistry`'s reflection scan pr
Deferred 2026-05-16. Finding verified as genuine: `ManagementEnvelope.Command` is typed
`object` and the recommended `IManagementCommand` marker-interface fix is sound. However, the
fix cannot be implemented within the `ManagementService` module: both `ManagementEnvelope` and
all ~50 `*Command` records live in `src/ScadaLink.Commons/Messages/Management/` (17 files),
which is outside this work item's permitted edit scope (`src/ScadaLink.ManagementService/**`,
all ~50 `*Command` records live in `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/` (17 files),
which is outside this work item's permitted edit scope (`src/ZB.MOM.WW.ScadaBridge.ManagementService/**`,
its tests, and this findings file only). Adding the marker interface, retyping the envelope,
and having `ManagementCommandRegistry` constrain its reflection scan to `IManagementCommand`
implementers is a cohesive Commons-module change and must be done there — also so the Commons
@@ -576,7 +576,7 @@ work item; no `ManagementService`-local change is appropriate.
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.ManagementService.Tests/ManagementActorTests.cs:1` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs:1` |
**Description**
@@ -618,7 +618,7 @@ pre-existing site-scope and DebugStreamHub suites. `dotnet test` -> 48 passed.
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:306`, `:1174` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:306`, `:1174` |
**Description**
@@ -672,7 +672,7 @@ Regression tests: `QueryDeployments_WithDesignRole_ReturnsUnauthorized`,
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:647``:659` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:647``:659` |
**Description**
@@ -716,7 +716,7 @@ this residual is documented in a code comment on the handler. Regression tests:
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:121``:131` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:121``:131` |
**Description**
@@ -761,7 +761,7 @@ leaked.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.ManagementService.Tests/ManagementActorTests.cs:1` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs:1` |
**Description**
@@ -797,7 +797,7 @@ Deployment user and an Admin user, in- and out-of-scope
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:153``:207`, `:336`, `:1302` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:153``:207`, `:336`, `:1302` |
**Description**
@@ -858,7 +858,7 @@ pre-fix code (the command fell through to "any authenticated user") and pass aft
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/AuditEndpoints.cs:358``:368`, `:397``:437` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs:358``:368`, `:397``:437` |
**Description**
@@ -928,7 +928,7 @@ mixed-set intersected.
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:1136``:1153` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:1136``:1153` |
**Resolution (2026-05-28):** Added `SmtpConfigPublicShape` projection that
returns every non-secret field plus a `HasCredentials` bool — never the
@@ -989,7 +989,7 @@ Add regression tests: `UpdateSmtpConfig_DoesNotEchoCredentialsInResponse` and
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.ManagementService.Tests/ManagementActorTests.cs:1`; `src/ScadaLink.ManagementService/ManagementActor.cs:1717``:1897` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs:1`; `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:1717``:1897` |
**Resolution (2026-05-28):** Added six `ManagementActorTests` cases covering the load-bearing bundle behaviours. Role gating: `ExportBundleCommand_WithAdminRole_ReturnsUnauthorized` (Export needs Design), `PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized`, and `ImportBundleCommand_WithDesignRole_ReturnsUnauthorized` (Preview/Import need Admin). Name resolution: `ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError` proves the `ResolveIds` `ManagementCommandException` surfaces verbatim with the missing entity type + name. Handler logic: `ImportBundleCommand_WithBlockerRow_AbortsBeforeApply` seeds a `ConflictKind.Blocker` preview and asserts `IBundleImporter.ApplyAsync` is never called and the error names the blocker; `ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName` seeds three rows for the same `(Template, "Dup")` key (Identical, Modified, Identical) and asserts only one resolution reaches `ApplyAsync` with last-write-wins (`Skip`, overriding the prior `Modified`/`Overwrite`). All bundle tests use substituted `IBundleExporter`/`IBundleImporter` via a new `AddBundleSubstitutes()` helper plus stub `GetAll*Async` repository returns. 106/106 ManagementService.Tests pass.
@@ -1095,7 +1095,7 @@ Update `Component-ManagementService.md`:
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:1276``:1295` |
| Location | `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:1276``:1295` |
**Resolution (2026-05-28):** Swapped the per-`InstanceId` `GetInstanceByIdAsync` lookup loop for a single `templateRepo.GetAllInstancesAsync()` bulk fetch, projected into a `Dictionary<int, int?>` keyed by `Instance.Id`. The unfiltered branch now hits the configuration database exactly twice (deployment records + instances) regardless of fleet size. Existing test `QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords` was updated to mock the bulk path and to assert `GetInstanceByIdAsync` is no longer used on the unfiltered branch; new regression test `QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup` pins `GetAllInstancesAsync` is invoked exactly once even with duplicate-instance records.
+15 -15
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.NotificationOutbox` |
| Module | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox` |
| Design doc | `docs/requirements/Component-NotificationOutbox.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -28,7 +28,7 @@ ConfigurationDatabase**. The outbox inherits two known defects from its sibling
that are reachable through `EmailNotificationDeliveryAdapter`: the OAuth2 SASL empty-user
bug (NS-021) ships every M365 send with `user=""`, and the
`InsertIfNotExistsAsync` check-then-act race (CD-015) lives on the outbox's ack-after-persist
hot path. Neither is a defect of code under `src/ScadaLink.NotificationOutbox/`, but both
hot path. Neither is a defect of code under `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/`, but both
are surfaced here because production dispatch and ingest go through these exact lines.
A secondary theme is **dispatcher-fire-and-forget audit writes** (`_ = _auditWriter.WriteAsync(...)`)
that can race the per-sweep scope dispose under the wrong DI graph, and a few smaller
@@ -63,7 +63,7 @@ from `Component-NotificationOutbox.md`. No Critical findings; two High, six Medi
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs:185-191` (calls `smtp.AuthenticateAsync("oauth2", token)`); root cause in `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:76-79` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/EmailNotificationDeliveryAdapter.cs:185-191` (calls `smtp.AuthenticateAsync("oauth2", token)`); root cause in `src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs:76-79` |
**Description**
@@ -106,7 +106,7 @@ _Unresolved._
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:348-360` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs:348-360` |
**Description**
@@ -146,7 +146,7 @@ _Unresolved._
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:334`, `src/ScadaLink.NotificationOutbox/Delivery/INotificationDeliveryAdapter.cs:22` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs:334`, `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/Delivery/INotificationDeliveryAdapter.cs:22` |
**Description**
@@ -186,14 +186,14 @@ _Unresolved._
| Severity | Medium |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:425-435`, `463-485` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs:425-435`, `463-485` |
**Description**
Both emission helpers issue `_ = _auditWriter.WriteAsync(evt);` — discarding the
returned task. `CentralAuditWriter.WriteAsync` opens its own `await using var scope =
_services.CreateAsyncScope();` and resolves a scoped `IAuditLogRepository` (verified
at `src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs:118-121`), so the writer is
at `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/CentralAuditWriter.cs:118-121`), so the writer is
defensively scope-independent. However the dispatcher already holds a per-sweep
`using var scope = _serviceProvider.CreateScope();` and the per-notification
`UpdateAsync` runs in that scope. The fire-and-forget pattern means:
@@ -238,15 +238,15 @@ _Unresolved._
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:127-132` (caller); root cause in `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs:33-45` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs:127-132` (caller); root cause in `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs:33-45` |
**Resolution (2026-05-28):** Closed by CD-015 — `NotificationOutboxRepository.InsertIfNotExistsAsync` (commit `ac96b83`) is now a single-statement `IF NOT EXISTS ... INSERT` via `ExecuteSqlInterpolatedAsync` with a `SqlException` filter swallowing duplicate-key violations (`2601`/`2627`) as a no-op (`return false`). The check-then-act window is eliminated; the at-least-once handoff contract holds and the actor's `PipeTo` success/failure projection no longer surfaces a permanent PK-violation back to the site. Verified in `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs:51-103`.
**Resolution (2026-05-28):** Closed by CD-015 — `NotificationOutboxRepository.InsertIfNotExistsAsync` (commit `ac96b83`) is now a single-statement `IF NOT EXISTS ... INSERT` via `ExecuteSqlInterpolatedAsync` with a `SqlException` filter swallowing duplicate-key violations (`2601`/`2627`) as a no-op (`return false`). The check-then-act window is eliminated; the at-least-once handoff contract holds and the actor's `PipeTo` success/failure projection no longer surfaces a permanent PK-violation back to the site. Verified in `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs:51-103`.
**Description**
`HandleSubmit``PersistAsync` calls `repository.InsertIfNotExistsAsync(notification)`
on `INotificationOutboxRepository`. The current implementation
(`src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs`)
(`src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs`)
does a check-then-act with no duplicate-key catch — documented as CD-015 (High,
Open). The Notification Outbox's documented contract is "at-least-once handoff with
ack-after-persist plus insert-if-not-exists on `NotificationId`" (CLAUDE.md,
@@ -280,7 +280,7 @@ remains the CD-015 raw-SQL `IF NOT EXISTS … INSERT` with `2601/2627` catch in
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:267-277` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs:267-277` |
**Description**
@@ -319,7 +319,7 @@ stateful intent (timeouts, circuit breakers) cannot accidentally lose state.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxOptions.cs:13`, `:22`, `:25`; `docs/requirements/Component-NotificationOutbox.md:152-160` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxOptions.cs:13`, `:22`, `:25`; `docs/requirements/Component-NotificationOutbox.md:152-160` |
**Description**
@@ -363,7 +363,7 @@ remains tracked separately.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:29-31`, `:251-259`; tests in `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs:29-31`, `:251-259`; tests in `tests/ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests/NotificationOutboxActorDispatchTests.cs` |
**Resolution (2026-05-28):** The `FallbackMaxRetries` / `FallbackRetryDelay` constants are documented for forward-compat with a deferred secondary-adapter path — when no SMTP configuration row exists, `EmailNotificationDeliveryAdapter` returns `Permanent("No SMTP configuration available")` before the retry-policy values are ever consulted, so the path is effectively unreachable from any current production caller. The reachable use of the constants — clamping a non-positive `SmtpConfiguration.MaxRetries` / `RetryDelay` (per NO-002) — is already covered by `TransientFailure_WithZeroMaxRetries_RetriesUsingFallback_DoesNotParkImmediately`, `TransientFailure_WithNegativeMaxRetries_RetriesUsingFallback_DoesNotParkImmediately`, and `TransientFailure_WithNonPositiveRetryDelay_UsesFallbackDelay_NotZero` in `NotificationOutboxActorDispatchTests.cs`. Mark untestable today and re-visit when a non-Email adapter (Teams etc.) makes the empty-SMTP-config branch genuinely deliverable.
@@ -401,7 +401,7 @@ the choice in the actor XML so a maintainer does not "fix" the unreachable code.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxOptions.cs:15-16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxOptions.cs:15-16` |
**Description**
@@ -440,7 +440,7 @@ requeue, or escalation. Matches `Component-NotificationOutbox.md §Monitoring`.
| Severity | Medium |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:469-477` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/NotificationOutboxActor.cs:469-477` |
**Description**
+38 -38
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.NotificationService` |
| Module | `src/ZB.MOM.WW.ScadaBridge.NotificationService` |
| Design doc | `docs/requirements/Component-NotificationService.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -124,7 +124,7 @@ prefix could be aggressively scrubbed out of unrelated log text (NS-025).
| Severity | Critical |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:96`, `src/ScadaLink.NotificationService/ServiceCollectionExtensions.cs:8` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:96`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs:8` |
**Description**
@@ -153,7 +153,7 @@ path. Fixed by the commit whose message references `NotificationService-001`.
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:157-167` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:157-167` |
**Description**
@@ -181,7 +181,7 @@ plus `SocketException`/`TimeoutException` are treated as transient. Regression t
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:144-147`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:163-166` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:144-147`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:163-166` |
**Description**
@@ -211,7 +211,7 @@ Regression tests `Send_Smtp5xxCommandException_ClassifiedPermanent`,
| Severity | High |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:118-119` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:118-119` |
**Description**
@@ -243,7 +243,7 @@ the resulting client is disposed.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:18`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:123` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs:18`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:123` |
**Description**
@@ -273,7 +273,7 @@ parks a buffered message) instead of silently negotiating TLS. Regression tests:
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/OAuth2TokenService.cs:14-15`, `src/ScadaLink.NotificationService/OAuth2TokenService.cs:30-35` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/OAuth2TokenService.cs:14-15`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/OAuth2TokenService.cs:30-35` |
**Description**
@@ -301,7 +301,7 @@ and `GetTokenAsync_SameCredentials_CachedPerCredential`.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationOptions.cs:11-14`, `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:16-20`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:111-140` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationOptions.cs:11-14`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs:16-20`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:111-140` |
**Description**
@@ -332,7 +332,7 @@ remain as operational fallback defaults. Regression tests:
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:136-137`, `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:50-53` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:136-137`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs:50-53` |
**Description**
@@ -362,7 +362,7 @@ the Central UI is a separate component and out of this module's scope.
| Severity | Medium — re-triaged: split into an in-scope log-leak fix (resolved) and a Commons-scoped at-rest-encryption / structured-credential follow-up (NotificationService-013, Deferred). |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:127-134`, `src/ScadaLink.NotificationService/OAuth2TokenService.cs:30-65`, `src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:9` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:127-134`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/OAuth2TokenService.cs:30-65`, `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/SmtpConfiguration.cs:9` |
**Description**
@@ -389,7 +389,7 @@ conflates two concerns with different ownership.
2. **At-rest encryption + structured-credential modelling (out of scope — Deferred).**
Encrypting `SmtpConfiguration.Credentials` at rest and replacing the brittle
colon-packed `string` with structured fields requires editing
`src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs` and the
`src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/SmtpConfiguration.cs` and the
ConfigurationDatabase EF layer — both outside this module. Tracked separately as
**NotificationService-013** (Deferred) so it is not lost.
@@ -400,7 +400,7 @@ conflates two concerns with different ownership.
| Severity | Medium |
| Category | Security |
| Status | Deferred |
| Location | `src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:9`, ConfigurationDatabase EF mapping |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/SmtpConfiguration.cs:9`, ConfigurationDatabase EF mapping |
**Description**
@@ -419,7 +419,7 @@ fields directly.
**Resolution**
Deferred — requires changes to `src/ScadaLink.Commons` and the ConfigurationDatabase
Deferred — requires changes to `src/ZB.MOM.WW.ScadaBridge.Commons` and the ConfigurationDatabase
component, which are outside the NotificationService module. To be addressed in a
Commons/ConfigurationDatabase-scoped change. The associated log-leak risk is resolved
under NotificationService-009.
@@ -431,7 +431,7 @@ under NotificationService-009.
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:121-154` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:121-154` |
**Description**
@@ -461,7 +461,7 @@ mask the original delivery exception. Regression tests
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:173-177`, `src/ScadaLink.Commons/Entities/Notifications/SmtpConfiguration.cs:5-15` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:173-177`, `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Notifications/SmtpConfiguration.cs:5-15` |
**Description**
@@ -477,11 +477,11 @@ Resolved 2026-05-16 (commit pending). Both issues confirmed against source.
1. **`SmtpPermanentException` placement (in scope — fixed).** The public exception type
was extracted from the bottom of `NotificationDeliveryService.cs` into its own file,
`src/ScadaLink.NotificationService/SmtpPermanentException.cs`, restoring the
`src/ZB.MOM.WW.ScadaBridge.NotificationService/SmtpPermanentException.cs`, restoring the
one-type-per-file layout. No behaviour change, so no dedicated regression test — the
move is verified by the module test suite still compiling and passing (56 tests green).
2. **`SmtpConfiguration` non-nullable strings (out of scope — re-triaged).**
`SmtpConfiguration` lives in `src/ScadaLink.Commons`, which is outside the
`SmtpConfiguration` lives in `src/ZB.MOM.WW.ScadaBridge.Commons`, which is outside the
NotificationService module's edit scope; it cannot be changed here. This is the same
Commons entity already flagged for follow-up under **NotificationService-013**
(Deferred). The `required`-members / documented-constructor change should be folded into
@@ -497,7 +497,7 @@ Resolved 2026-05-16 (commit pending). Both issues confirmed against source.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs`, `tests/ScadaLink.NotificationService.Tests/OAuth2TokenServiceTests.cs` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationDeliveryServiceTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/OAuth2TokenServiceTests.cs` |
**Description**
@@ -543,7 +543,7 @@ Module test suite is green at 56 tests.
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:214-228`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:308-312`, `src/ScadaLink.NotificationService/OAuth2TokenService.cs:56-84` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:214-228`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:308-312`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/OAuth2TokenService.cs:56-84` |
**Description**
@@ -564,7 +564,7 @@ Resolved 2026-05-17. Root cause confirmed against source — `DeliverBufferedAsy
| Severity | High |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:96-148`, `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:308-312` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:96-148`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:308-312` |
**Description**
@@ -585,7 +585,7 @@ Resolved 2026-05-17. Root cause confirmed — `SendAsync` had only three catch c
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:46-67` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs:46-67` |
**Description**
@@ -606,11 +606,11 @@ Resolved 2026-05-17. Root cause confirmed — `AuthenticateAsync` returned silen
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationOptions.cs:1-15`, `src/ScadaLink.NotificationService/ServiceCollectionExtensions.cs:10-11`, `src/ScadaLink.Host/SiteServiceRegistration.cs:70` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationOptions.cs:1-15`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs:10-11`, `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs:70` |
**Description**
`NotificationOptions` (with `ConnectionTimeoutSeconds` and `MaxConcurrentConnections`) is bound from the `ScadaLink:Notification` configuration section in two places — `ServiceCollectionExtensions.AddNotificationService` (`AddOptions<NotificationOptions>().BindConfiguration(...)`) and again in `Host/SiteServiceRegistration.cs:70` (`services.Configure<NotificationOptions>(...)`). However, a repo-wide search shows no code ever injects `IOptions<NotificationOptions>` or otherwise reads either property. When NS-007 enforced the connection timeout and concurrency limit, it sourced both values from the per-site `SmtpConfiguration` entity, not from `NotificationOptions` — so this options class is now entirely dead configuration. Its XML doc still claims it "provides fallback defaults and operational limits", which is misleading: nothing falls back to it. The double binding is also redundant. Dead, falsely-documented configuration invites an operator to set `ScadaLink:Notification:ConnectionTimeoutSeconds` and expect it to take effect, when it has no effect at all.
`NotificationOptions` (with `ConnectionTimeoutSeconds` and `MaxConcurrentConnections`) is bound from the `ScadaBridge:Notification` configuration section in two places — `ServiceCollectionExtensions.AddNotificationService` (`AddOptions<NotificationOptions>().BindConfiguration(...)`) and again in `Host/SiteServiceRegistration.cs:70` (`services.Configure<NotificationOptions>(...)`). However, a repo-wide search shows no code ever injects `IOptions<NotificationOptions>` or otherwise reads either property. When NS-007 enforced the connection timeout and concurrency limit, it sourced both values from the per-site `SmtpConfiguration` entity, not from `NotificationOptions` — so this options class is now entirely dead configuration. Its XML doc still claims it "provides fallback defaults and operational limits", which is misleading: nothing falls back to it. The double binding is also redundant. Dead, falsely-documented configuration invites an operator to set `ScadaBridge:Notification:ConnectionTimeoutSeconds` and expect it to take effect, when it has no effect at all.
**Recommendation**
@@ -627,7 +627,7 @@ Resolved 2026-05-17. Root cause confirmed — `NotificationOptions` was bound bu
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:237-255` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:237-255` |
**Description**
@@ -648,9 +648,9 @@ Resolved 2026-05-17. All three issues confirmed against source. The hand-rolled
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:18-442`, `src/ScadaLink.NotificationService/ServiceCollectionExtensions.cs:20-21`, `src/ScadaLink.Commons/Interfaces/Services/INotificationDeliveryService.cs:1-33`, `src/ScadaLink.Host/Program.cs:77` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:18-442`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs:20-21`, `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/INotificationDeliveryService.cs:1-33`, `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:77` |
**Resolution** — Executed option 1. Deleted `src/ScadaLink.NotificationService/NotificationDeliveryService.cs`, `src/ScadaLink.Commons/Interfaces/Services/INotificationDeliveryService.cs` (also retires `NotificationResult` + `BufferedNotification`), and the orphaned `tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs` suite; reduced `AddNotificationService` to the shared SMTP primitives (`OAuth2TokenService`, `Func<ISmtpClientWrapper>`, `NotificationOptions`), updated `CompositionRootTests` (assert the primitives instead of the dead types), and removed the `Notification_Send_MockSmtp_Delivers` assertion in `IntegrationSurfaceTests` (central delivery is covered by `EmailNotificationDeliveryAdapterTests`). Grep-verified `grep -rn "INotificationDeliveryService\|NotificationDeliveryService\|NotificationResult\|BufferedNotification\|DeliverBufferedAsync" --include="*.cs" src/ tests/` before delete: zero production callers (only XML-doc cross-references in NS, MailKit wrapper, NotificationOptions and `EmailNotificationDeliveryAdapter`, plus the dead test files); cross-reference comments updated to remove the stale class references. `dotnet build ScadaLink.slnx` succeeds (0 warnings, 0 errors); affected test projects all pass (`NotificationService.Tests` 52/52, `NotificationOutbox.Tests` 86/86 on rerun — one flaky timing-sensitive Akka.TestKit test unrelated to NS-019, `Host.Tests` 205/205); `IntegrationTests` 64/66 with two pre-existing failures in `NotificationOutboxFlowTests` (SQLite "near IF: syntax error", reproducible on pristine `main`, unrelated to NS-019).
**Resolution** — Executed option 1. Deleted `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs`, `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/INotificationDeliveryService.cs` (also retires `NotificationResult` + `BufferedNotification`), and the orphaned `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationDeliveryServiceTests.cs` suite; reduced `AddNotificationService` to the shared SMTP primitives (`OAuth2TokenService`, `Func<ISmtpClientWrapper>`, `NotificationOptions`), updated `CompositionRootTests` (assert the primitives instead of the dead types), and removed the `Notification_Send_MockSmtp_Delivers` assertion in `IntegrationSurfaceTests` (central delivery is covered by `EmailNotificationDeliveryAdapterTests`). Grep-verified `grep -rn "INotificationDeliveryService\|NotificationDeliveryService\|NotificationResult\|BufferedNotification\|DeliverBufferedAsync" --include="*.cs" src/ tests/` before delete: zero production callers (only XML-doc cross-references in NS, MailKit wrapper, NotificationOptions and `EmailNotificationDeliveryAdapter`, plus the dead test files); cross-reference comments updated to remove the stale class references. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` succeeds (0 warnings, 0 errors); affected test projects all pass (`NotificationService.Tests` 52/52, `NotificationOutbox.Tests` 86/86 on rerun — one flaky timing-sensitive Akka.TestKit test unrelated to NS-019, `Host.Tests` 205/205); `IntegrationTests` 64/66 with two pre-existing failures in `NotificationOutboxFlowTests` (SQLite "near IF: syntax error", reproducible on pristine `main`, unrelated to NS-019).
**Description**
@@ -660,8 +660,8 @@ The current source does not match. `NotificationDeliveryService` is a site-shape
Who actually calls it?
- **Sites** do **not**. `SiteServiceRegistration.cs:33-38` documents the deliberate omission: "AddNotificationService() is intentionally NOT registered on the site path." Sites register `NotificationForwarder` (in `ScadaLink.StoreAndForward`) as the S&F handler for `StoreAndForwardCategory.Notification` (`AkkaHostedService.cs:654-660`), which Asks the central comms actor and never touches SMTP. `ScriptRuntimeContext.NotifyHelper` (in `SiteRuntime`) enqueues directly to S&F as a serialized `NotificationSubmit`, **not** via `INotificationDeliveryService.SendAsync`.
- **Central** registers it (`Program.cs:77` calls `AddNotificationService`) but no central component resolves it. The central notification dispatcher is `NotificationOutboxActor``INotificationDeliveryAdapter``EmailNotificationDeliveryAdapter`. The adapter is a full re-implementation of the connect/auth/send/disconnect sequence (see `EmailNotificationDeliveryAdapter.cs:163-222`) — it deliberately does not call `NotificationDeliveryService.DeliverAsync` (XML-doc on the adapter says "Reuses the `ScadaLink.NotificationService` SMTP machinery — `ISmtpClientWrapper`, `SmtpTlsModeParser`, `OAuth2TokenService` and the typed `SmtpPermanentException`", i.e. only the leaf primitives).
- **Sites** do **not**. `SiteServiceRegistration.cs:33-38` documents the deliberate omission: "AddNotificationService() is intentionally NOT registered on the site path." Sites register `NotificationForwarder` (in `ZB.MOM.WW.ScadaBridge.StoreAndForward`) as the S&F handler for `StoreAndForwardCategory.Notification` (`AkkaHostedService.cs:654-660`), which Asks the central comms actor and never touches SMTP. `ScriptRuntimeContext.NotifyHelper` (in `SiteRuntime`) enqueues directly to S&F as a serialized `NotificationSubmit`, **not** via `INotificationDeliveryService.SendAsync`.
- **Central** registers it (`Program.cs:77` calls `AddNotificationService`) but no central component resolves it. The central notification dispatcher is `NotificationOutboxActor``INotificationDeliveryAdapter``EmailNotificationDeliveryAdapter`. The adapter is a full re-implementation of the connect/auth/send/disconnect sequence (see `EmailNotificationDeliveryAdapter.cs:163-222`) — it deliberately does not call `NotificationDeliveryService.DeliverAsync` (XML-doc on the adapter says "Reuses the `ZB.MOM.WW.ScadaBridge.NotificationService` SMTP machinery — `ISmtpClientWrapper`, `SmtpTlsModeParser`, `OAuth2TokenService` and the typed `SmtpPermanentException`", i.e. only the leaf primitives).
The `NotificationDeliveryService` class, its `DeliverBufferedAsync`, the `Func<ISmtpClientWrapper>` registration consumed only by it, and the `INotificationDeliveryService` interface (still in Commons) and `NotificationResult` record are therefore dead code that contradicts the design. Worse, every prior finding NS-001..NS-018 was reviewed and resolved against this dead path. The 56-test green test suite (NS-012 resolution note) exercises behaviour no production caller invokes — it gives a false sense of coverage. The misleading XML doc on `NotificationDeliveryService` ("WP-11/12: Notification delivery via SMTP") tells a maintainer this is *the* delivery path; the registration on central does the same.
@@ -671,7 +671,7 @@ Risk: an operator following the design doc will look here for "the central email
Decide and execute one of:
1. **Delete `NotificationDeliveryService`, `DeliverBufferedAsync`, the `BufferedNotification` payload type, the `Func<ISmtpClientWrapper>` scoped registration (move it to NotificationOutbox if still needed there — it already has its own), and `INotificationDeliveryService`/`NotificationResult` in Commons.** Reduce `AddNotificationService` to registering the shared primitives — `OAuth2TokenService`, `ISmtpClientWrapper` factory, `NotificationOptions`. Delete the NS-001..NS-018 tests that target the orphaned path; rebase the ones that exercise primitives (`SmtpErrorClassifier`, `SmtpTlsModeParser`, `CredentialRedactor`, `EmailAddressValidator`, `MailKitSmtpClientWrapper`, `OAuth2TokenService`) which remain genuinely shared. Update `CompositionRootTests` (`tests/ScadaLink.Host.Tests/CompositionRootTests.cs:208-209`) and `IntegrationSurfaceTests` (`tests/ScadaLink.IntegrationTests/IntegrationSurfaceTests.cs:122-135`) to drop the stale assertions.
1. **Delete `NotificationDeliveryService`, `DeliverBufferedAsync`, the `BufferedNotification` payload type, the `Func<ISmtpClientWrapper>` scoped registration (move it to NotificationOutbox if still needed there — it already has its own), and `INotificationDeliveryService`/`NotificationResult` in Commons.** Reduce `AddNotificationService` to registering the shared primitives — `OAuth2TokenService`, `ISmtpClientWrapper` factory, `NotificationOptions`. Delete the NS-001..NS-018 tests that target the orphaned path; rebase the ones that exercise primitives (`SmtpErrorClassifier`, `SmtpTlsModeParser`, `CredentialRedactor`, `EmailAddressValidator`, `MailKitSmtpClientWrapper`, `OAuth2TokenService`) which remain genuinely shared. Update `CompositionRootTests` (`tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs:208-209`) and `IntegrationSurfaceTests` (`tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs:122-135`) to drop the stale assertions.
2. **Keep the class as the central-only Email delivery primitive** and rewrite `EmailNotificationDeliveryAdapter` to delegate to it. This is the smaller diff but the larger semantic burden — `NotificationDeliveryService.SendAsync` returns `NotificationResult` (Success / WasBuffered) which cannot encode the three-way `DeliveryOutcome` (Success / Transient / Permanent) the outbox needs, so the contract still has to change.
@@ -684,7 +684,7 @@ Recommended path is option 1: the parallel implementation in `EmailNotificationD
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:654-660`, NS-001 resolution note (this file) |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:654-660`, NS-001 resolution note (this file) |
**Resolution (2026-05-28):** Added a `_notificationDeliveryHandlerRegistered` sentinel field on `AkkaHostedService` and gated the canonical `NotificationForwarder` registration with an `InvalidOperationException` guard — a future code path that re-introduces the dead NS-001 site-SMTP handler now fails fast at startup with an explicit NS-020 diagnostic, rather than silently overwriting `RegisterDeliveryHandler`'s last-write-wins map and inverting the central-only design. The sentinel's XML doc cross-references NS-001/NS-019/NS-020 so a maintainer searching for the `Notification` S&F handler finds the one canonical registration and its history.
@@ -707,7 +707,7 @@ Mark the NS-001 resolution note in this file as **superseded by NS-019** with a
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:76-79` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs:76-79` |
**Description**
@@ -736,7 +736,7 @@ Pass the sender mailbox into the wrapper's `AuthenticateAsync` path. The cleanes
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:14`, `src/ScadaLink.NotificationService/ServiceCollectionExtensions.cs:19` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/MailKitSmtpClientWrapper.cs:14`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs:19` |
**Resolution (2026-05-28):** Took option (a) — updated the class-level XML doc on `MailKitSmtpClientWrapper` with an explicit lifetime section (NS-022) describing single-connection ownership and the per-delivery factory contract (NOT a pool; `MailKit.Net.Smtp.SmtpClient` holds one TCP/TLS connection and is not thread-safe; the DI `Func<ISmtpClientWrapper>` is a factory, callers run connect/auth/send/disconnect/dispose per send). A field-level comment was added to the `_client` declaration cross-referencing the class docs so a maintainer hunting from the field-initializer immediately sees the constraint. The wrapper code itself is unchanged — option (b) (transient SmtpClient per Send) was deliberately not taken because it would re-handshake TLS per email which is materially more expensive than the current per-delivery factory model the callers already implement. The concurrent-connection limit regression on the central-side delivery path (per-site semaphore on `EmailNotificationDeliveryAdapter`) is out of NotificationService scope and tracked separately.
@@ -761,7 +761,7 @@ Document the per-send lifecycle on `MailKitSmtpClientWrapper` (XML on the class:
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:12-17`, `src/ScadaLink.Commons/Interfaces/Services/INotificationDeliveryService.cs:3-12`, `src/ScadaLink.NotificationService/ServiceCollectionExtensions.cs:8-9` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:12-17`, `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Services/INotificationDeliveryService.cs:3-12`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs:8-9` |
**Resolution (2026-05-28):** Closed by NS-019 — both `NotificationDeliveryService.cs` and `INotificationDeliveryService.cs` were removed in commit `ac96b83`, and `ServiceCollectionExtensions.AddNotificationService`'s XML doc was rewritten in the same commit to describe the central-only design (shared SMTP primitives consumed by `EmailNotificationDeliveryAdapter`, with an explicit NS-019 cross-reference and a note that sites no longer deliver notifications). No stale XML docs remain in this module.
@@ -787,9 +787,9 @@ Tied to NS-019: if the orphan classes are deleted, this finding closes itself. I
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.NotificationService.Tests/NotificationDeliveryServiceTests.cs`, `tests/ScadaLink.IntegrationTests/IntegrationSurfaceTests.cs:118-136`, `tests/ScadaLink.Host.Tests/CompositionRootTests.cs:207-209` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationDeliveryServiceTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs:118-136`, `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs:207-209` |
**Resolution (2026-05-28):** Closed by NS-019 — the orphaned `NotificationDeliveryService` class, its `INotificationDeliveryService` Commons interface, and the associated `NotificationDeliveryServiceTests.cs` test file (~40 tests asserting `SendAsync`/`DeliverBufferedAsync` behaviour against a code path no production caller resolves) were all deleted in the NS-019 fix commit. Verification: a directory listing of `tests/ScadaLink.NotificationService.Tests/` shows only `CredentialRedactorTests.cs`, `MailKitSmtpClientWrapperTests.cs`, `NotificationOptionsTests.cs`, `OAuth2TokenServiceTests.cs`, `SmtpErrorClassifierTests.cs`, and `SmtpTlsModeParserTests.cs` — every retained file exercises a primitive that the central NotificationOutbox `EmailNotificationDeliveryAdapter` still depends on, so the false-coverage signal this finding called out no longer exists. The "no test affirms the central-only invariant" gap was the consequence of the orphaned tests existing; with them gone, the module test suite genuinely scopes to the shared SMTP primitives. The architecture-test recommendation (banning new consumers of `INotificationDeliveryService`) is moot once the interface itself is gone.
**Resolution (2026-05-28):** Closed by NS-019 — the orphaned `NotificationDeliveryService` class, its `INotificationDeliveryService` Commons interface, and the associated `NotificationDeliveryServiceTests.cs` test file (~40 tests asserting `SendAsync`/`DeliverBufferedAsync` behaviour against a code path no production caller resolves) were all deleted in the NS-019 fix commit. Verification: a directory listing of `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/` shows only `CredentialRedactorTests.cs`, `MailKitSmtpClientWrapperTests.cs`, `NotificationOptionsTests.cs`, `OAuth2TokenServiceTests.cs`, `SmtpErrorClassifierTests.cs`, and `SmtpTlsModeParserTests.cs` — every retained file exercises a primitive that the central NotificationOutbox `EmailNotificationDeliveryAdapter` still depends on, so the false-coverage signal this finding called out no longer exists. The "no test affirms the central-only invariant" gap was the consequence of the orphaned tests existing; with them gone, the module test suite genuinely scopes to the shared SMTP primitives. The architecture-test recommendation (banning new consumers of `INotificationDeliveryService`) is moot once the interface itself is gone.
**Description**
@@ -799,7 +799,7 @@ In particular there is **no test in this module** that affirms the central-only
- No test that `AddNotificationService()` registered on a *site* role would be inert / no-op'd, or that `SiteServiceRegistration.Configure` does **not** call `AddNotificationService` (an obvious regression vector — re-adding it would silently restore the orphaned site-delivery path).
- No test that confirms `INotificationDeliveryService` has no production consumer (i.e. an architecture test that fails if anyone re-introduces a constructor parameter or `GetRequiredService<INotificationDeliveryService>()` call).
- The cross-module `CompositionRootTests` (`tests/ScadaLink.Host.Tests/CompositionRootTests.cs:208-209`) still asserts `NotificationDeliveryService` and `INotificationDeliveryService` are registered, locking in the orphan rather than catching it.
- The cross-module `CompositionRootTests` (`tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs:208-209`) still asserts `NotificationDeliveryService` and `INotificationDeliveryService` are registered, locking in the orphan rather than catching it.
- `IntegrationSurfaceTests.cs:122-125` constructs `NotificationDeliveryService` directly to validate "the integration surface" — testing a surface that no script actually crosses.
**Recommendation**
@@ -807,7 +807,7 @@ In particular there is **no test in this module** that affirms the central-only
After NS-019 is decided:
1. If the orphan is deleted, remove the orphaned-path tests (NS-001/004/005/007/008/009/010/014/015/016/017/018-style tests targeting `SendAsync`/`DeliverBufferedAsync`). Retain `SmtpErrorClassifierTests`, `SmtpTlsModeParserTests`, `CredentialRedactorTests`, `OAuth2TokenServiceTests`, and `MailKitSmtpClientWrapperTests` (primitives genuinely shared). Update `CompositionRootTests` to drop the stale rows and `IntegrationSurfaceTests` to call the live path via `INotificationDeliveryAdapter`/`EmailNotificationDeliveryAdapter`.
2. Add a one-shot architecture test in `tests/ScadaLink.Architecture.Tests` (if it exists, else this module) that scans for direct references to `INotificationDeliveryService` outside this project and the obsolete-interface declaration in Commons, failing if any new consumer reappears.
2. Add a one-shot architecture test in `tests/ZB.MOM.WW.ScadaBridge.Architecture.Tests` (if it exists, else this module) that scans for direct references to `INotificationDeliveryService` outside this project and the obsolete-interface declaration in Commons, failing if any new consumer reappears.
### NotificationService-025 — `CredentialRedactor` over-masks: any 4-character credential component is masked anywhere it appears, including unrelated log text
@@ -816,7 +816,7 @@ After NS-019 is decided:
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/CredentialRedactor.cs:34-48` |
| Location | `src/ZB.MOM.WW.ScadaBridge.NotificationService/CredentialRedactor.cs:34-48` |
**Resolution (2026-05-28):** Tightened the policy per the recommendation —
only the LAST colon-separated component (password in Basic / clientSecret
+1 -1
View File
@@ -1,6 +1,6 @@
# Code Reviews
Comprehensive, per-module code reviews of the ScadaLink codebase. Each module (one
Comprehensive, per-module code reviews of the ScadaBridge codebase. Each module (one
buildable project under `src/`) has its own folder containing a `findings.md`. This
README is the aggregated index — the single place to see all outstanding work.
+3 -3
View File
@@ -1,15 +1,15 @@
# Code Review Process
This document describes how to perform a comprehensive, per-module code review of
the ScadaLink codebase and how to track findings to resolution.
the ScadaBridge codebase and how to track findings to resolution.
A **module** is one buildable project under `src/` (e.g. `src/ScadaLink.TemplateEngine`).
A **module** is one buildable project under `src/` (e.g. `src/ZB.MOM.WW.ScadaBridge.TemplateEngine`).
Each module has its own folder under `code-reviews/` containing a single `findings.md`.
## 1. Before you start
1. Pick the module to review. Its folder is `code-reviews/<Module>/` where `<Module>`
is the project name with the `ScadaLink.` prefix stripped.
is the project name with the `ZB.MOM.WW.ScadaBridge.` prefix stripped.
2. Identify the design context for the module:
- Its component design doc: `docs/requirements/Component-<Name>.md`.
- The relevant **Key Design Decisions** in `CLAUDE.md`.
+30 -30
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.Security` |
| Module | `src/ZB.MOM.WW.ScadaBridge.Security` |
| Design doc | `docs/requirements/Component-Security.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -117,7 +117,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:37-47` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs:37-47` |
**Description**
@@ -153,7 +153,7 @@ and `AuthenticateAsync_NoTlsTransport_RejectedWithoutAllowInsecure`.
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Security/ServiceCollectionExtensions.cs:16-23` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs:16-23` |
**Description**
@@ -186,7 +186,7 @@ tuning left as a separate, lower-priority improvement.)
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Security/JwtTokenService.cs:33`, `src/ScadaLink.Security/SecurityOptions.cs:42` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs:33`, `src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs:42` |
**Description**
@@ -222,7 +222,7 @@ corrected to state the requirement. Regression tests
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:66`, `:138`, `:157-159` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs:66`, `:138`, `:157-159` |
**Description**
@@ -258,7 +258,7 @@ Regression tests `BuildFallbackUserDn_UsesConfiguredUserIdAttribute`,
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:157-159` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs:157-159` |
**Description**
@@ -297,7 +297,7 @@ Regression tests `BuildFallbackUserDn_EscapesDnMetacharacters`,
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Security/JwtTokenService.cs:67-75`, `:56-59` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs:67-75`, `:56-59` |
**Description**
@@ -311,7 +311,7 @@ silently exploitable.
**Recommendation**
Set a fixed `Issuer` and `Audience` (e.g. `"scadalink-central"`) when generating tokens
Set a fixed `Issuer` and `Audience` (e.g. `"scadabridge-central"`) when generating tokens
and enable `ValidateIssuer`/`ValidateAudience` with the matching expected values during
validation.
@@ -320,7 +320,7 @@ validation.
Resolved 2026-05-16 (commit `pending`). Confirmed: `GenerateToken` set neither `iss`
nor `aud` and `ValidateToken` had `ValidateIssuer = false`/`ValidateAudience = false`.
`GenerateToken` now binds `JwtTokenService.TokenIssuer`/`TokenAudience`
(both `"scadalink-central"`) into every token, and `ValidateToken` enables
(both `"scadabridge-central"`) into every token, and `ValidateToken` enables
`ValidateIssuer`/`ValidateAudience` against those fixed values — a token signed with
the shared key but a foreign issuer is now rejected. Regression tests
`GenerateToken_SetsIssuerAndAudience`, `ValidateToken_RejectsTokenWithWrongIssuer`,
@@ -333,7 +333,7 @@ the shared key but a foreign issuer is now rejected. Regression tests
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Security/JwtTokenService.cs:40`, `:111-123` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs:40`, `:111-123` |
**Description**
@@ -381,7 +381,7 @@ Security-side defect — the reset-on-refresh bug — is fully fixed here. Regre
| Severity | Low |
| Category | Performance & resource management |
| Status | Deferred |
| Location | `src/ScadaLink.Security/RoleMapper.cs:25-48` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs:25-48` |
**Description**
@@ -403,7 +403,7 @@ issues one `GetScopeRulesForMappingAsync` round-trip per matched Deployment mapp
genuine N+1 on the login / 15-minute-refresh path. However, the only correct fix
(a batch `GetScopeRulesForMappingsAsync(IEnumerable<int>)` repository method, or an
eager-load navigation property) requires changes to `ISecurityRepository`
(`src/ScadaLink.Commons`) and `SecurityRepository` (`src/ScadaLink.ConfigurationDatabase`).
(`src/ZB.MOM.WW.ScadaBridge.Commons`) and `SecurityRepository` (`src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase`).
Both are outside the Security module's permitted edit scope for this review pass, and the
existing `ISecurityRepository` surface offers no per-set scope-rule query, so the N+1
cannot be removed from within `RoleMapper.cs` alone. Severity is Low (bounded by the
@@ -418,7 +418,7 @@ rules in a single call.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:42`, `:46`, `:51`, `:56-57`, `:67-73`, `:135`, `:139-145` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs:42`, `:46`, `:51`, `:56-57`, `:67-73`, `:135`, `:139-145` |
**Description**
@@ -490,7 +490,7 @@ of the document and the code.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.Security.Tests/UnitTest1.cs` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/UnitTest1.cs` |
**Description**
@@ -529,7 +529,7 @@ LDAP-timeout coverage (Security-009) plus extra no-service-account / DN-path edg
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:78-118` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs:78-118` |
**Description**
@@ -581,7 +581,7 @@ treating a genuine empty-groups result as a successful login. Regression tests
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:258-269` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs:258-269` |
**Description**
@@ -621,7 +621,7 @@ testing. Regression tests `ExtractFirstRdnValue_EscapedComma_KeepsWholeGroupName
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Security/JwtTokenService.cs:156-169` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs:156-169` |
**Description**
@@ -665,7 +665,7 @@ invariant. Regression tests `RefreshToken_IdleExpiredPrincipal_ReturnsNull`,
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:20-21`, `:80`, `:122`, `:169`, `:193` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs:20-21`, `:80`, `:122`, `:169`, `:193` |
**Description**
@@ -707,7 +707,7 @@ use the single canonical identity. Regression tests
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Security/RoleMapper.cs:30-31`, `:41-55`, `:59` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs:30-31`, `:41-55`, `:59` |
**Description**
@@ -765,17 +765,17 @@ after.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:8-58`; `src/ScadaLink.Security/AuthorizationPolicies.cs:113-143` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/SiteScopeAuthorizationHandler.cs:8-58`; `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:113-143` |
**Description**
The module declares `SiteScopeRequirement` (an `IAuthorizationRequirement` carrying a
`TargetSiteId`) and the matching `SiteScopeAuthorizationHandler` that combines the
Deployment role claim with the `SiteId` claims to enforce the design's site-scoping
rule. The handler is registered in `AddScadaLinkAuthorization`
rule. The handler is registered in `AddScadaBridgeAuthorization`
(`services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>()`). But
no `AddPolicy` call ever wires the requirement to a named policy, and a grep across
`src/ScadaLink.CentralUI` and `src/ScadaLink.ManagementService` confirms that **no
`src/ZB.MOM.WW.ScadaBridge.CentralUI` and `src/ZB.MOM.WW.ScadaBridge.ManagementService` confirms that **no
production code ever instantiates `new SiteScopeRequirement(...)` or calls
`AuthorizeAsync(...)` with one** — the only callers are the unit tests in
`SecurityTests.cs:1146,1166,1185,1203`. The design + CLAUDE.md state that "Deployment
@@ -808,7 +808,7 @@ piecemeal.
Resolved 2026-05-28 (commit pending) per recommendation option (a): deleted
`SiteScopeRequirement` and `SiteScopeAuthorizationHandler` outright, along with the
unwired `services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>()`
registration in `AuthorizationPolicies.AddScadaLinkAuthorization` and the four
registration in `AuthorizationPolicies.AddScadaBridgeAuthorization` and the four
isolation tests in `SecurityTests.cs`. `SiteScopeService` (CentralUI-002) is now
documented as the sole site-scoping mechanism — the stale "mirrors
SiteScopeAuthorizationHandler" comment in `SiteScopeService.ResolveAsync` was rewritten
@@ -822,7 +822,7 @@ new report pages now consume `SiteScopeService`).
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.Security/RoleMapper.cs:41`; `src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs:36`; `src/ScadaLink.Security/AuthorizationPolicies.cs:118,121,124,95,107` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs:41`; `src/ZB.MOM.WW.ScadaBridge.Security/SiteScopeAuthorizationHandler.cs:36`; `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs:118,121,124,95,107` |
**Description**
@@ -852,11 +852,11 @@ will then either succeed everywhere or fail to compile.
**Resolution**
Resolved 2026-05-28 (commit pending). Added `src/ScadaLink.Security/Roles.cs` holding
Resolved 2026-05-28 (commit pending). Added `src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs` holding
`Admin`/`Design`/`Deployment`/`Audit`/`AuditReadOnly` as `public const string`
fields. Replaced every magic-string occurrence in this module:
`RoleMapper.MapGroupsToRolesAsync` now compares against `Roles.Deployment`;
`AuthorizationPolicies.AddScadaLinkAuthorization` binds `RequireClaim(...)` to
`AuthorizationPolicies.AddScadaBridgeAuthorization` binds `RequireClaim(...)` to
`Roles.{Admin,Design,Deployment}`; `OperationalAuditRoles` /
`AuditExportRoles` are now built from `Roles.Admin`, `Roles.Audit`, `Roles.AuditReadOnly`.
`SiteScopeAuthorizationHandler.cs` was deleted under Security-017, so its
@@ -870,7 +870,7 @@ single edit or fails to compile.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Security/LdapAuthService.cs:85-89`, `:147-151` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs:85-89`, `:147-151` |
**Description**
@@ -903,7 +903,7 @@ distinct error message.
**Resolution**
Resolved 2026-05-28 (commit pending). Added a new `ServiceAccountBindException`
(`src/ScadaLink.Security/ServiceAccountBindException.cs`) — deliberately NOT an
(`src/ZB.MOM.WW.ScadaBridge.Security/ServiceAccountBindException.cs`) — deliberately NOT an
`LdapException` subtype so it short-circuits the generic LDAP catch — and a private
`BindServiceAccountAsync` helper on `LdapAuthService` that wraps both service-account
rebind sites (the post-user-bind group-lookup rebind AND the `ResolveUserDnAsync`
@@ -926,7 +926,7 @@ is the closest meaningful unit-level coverage.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Security/SecurityOptions.cs:6-7`, `:36-37`; `src/ScadaLink.Security/ServiceCollectionExtensions.cs:13-30` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs:6-7`, `:36-37`; `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs:13-30` |
**Resolution (2026-05-28):** added `SecurityOptionsValidator`
(`IValidateOptions<SecurityOptions>`) that rejects empty/whitespace
@@ -972,7 +972,7 @@ every required `SecurityOptions` field is enforced at startup, not at first use.
| Severity | Low |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Security/SecurityOptions.cs:100-108`; `src/ScadaLink.Security/ServiceCollectionExtensions.cs:54-59` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs:100-108`; `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs:54-59` |
**Resolution (2026-05-28):** Added `ILoggerFactory` to the cookie-options
`Configure` callback in `AddSecurity` so an explicit `RequireHttpsCookie=false`
+9 -9
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.SiteCallAudit` |
| Module | `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit` |
| Design doc | `docs/requirements/Component-SiteCallAudit.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -51,7 +51,7 @@ tests using a shared `MsSqlMigrationFixture`.
| Severity | Medium |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs:32-46`, `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs:147-151` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs:32-46`, `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs:147-151` |
**Description**
@@ -107,7 +107,7 @@ children (Site Runtime hierarchy) — not to leaf cluster singletons.
| Severity | Low |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:455-462` (singleton wiring), `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs:153-193` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:455-462` (singleton wiring), `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs:153-193` |
**Resolution (2026-05-28):** Added a `CoordinatedShutdown` task in the `cluster-leave` phase (named `drain-site-call-audit-singleton`) that issues an explicit `GracefulStop(10s)` to the `SiteCallAudit` cluster singleton manager before the cluster-leave proceeds. Akka.NET's singleton handover already waits for the active actor's `ReceiveAsync` task to complete before signalling `HandOverDone`, so an in-flight EF `UpsertAsync` (and its SQL round-trip) drains on the old node before the new singleton starts on the other central node — closing the seam where the new singleton could race a still-running upsert on the old node. The 10-second timeout is bounded so a misbehaving upsert cannot stall coordinated shutdown indefinitely; on timeout the existing `PoisonPill` termination path takes over and the repository's monotonic-upsert + 2601/2627 duplicate-key swallow remain as the storage-state safety net. Pattern is suitable for the `NotificationOutbox` singleton too; deferred to keep this change scoped.
@@ -153,14 +153,14 @@ Notification Outbox sibling has the same pattern.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs:153-193` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs:153-193` |
**Description**
The combined-telemetry hot path (`AuditLogIngestActor.OnCachedTelemetryAsync`)
stamps `IngestedAtUtc = DateTime.UtcNow` on both the `AuditLog` row and the
`SiteCall` row at central-side persist time
(`src/ScadaLink.AuditLog/Central/AuditLogIngestActor.cs:238-239`). The design
(`src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogIngestActor.cs:238-239`). The design
doc treats `IngestedAtUtc` as "central ingested (or last refreshed) this row"
— a central-side timestamp.
@@ -197,7 +197,7 @@ because callers cannot in general know the actor is colocated on central.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs:23-30` (actor XML), `src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs:8-13`, `docs/requirements/Component-SiteCallAudit.md:24-32` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs:23-30` (actor XML), `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/ServiceCollectionExtensions.cs:8-13`, `docs/requirements/Component-SiteCallAudit.md:24-32` |
**Description**
@@ -238,7 +238,7 @@ doc update is tracked separately.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs:548-563` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditActor.cs:548-563` |
**Description**
@@ -297,9 +297,9 @@ relay-path crash.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs:335-392` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs:335-392` |
**Resolution (2026-05-28):** Added `SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow` to `tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs` — drives six rows interleaved as `stuck/non-stuck` × 3 (oldest-first), then issues three page-size-1 stuck-only queries. The cursor between each page deliberately lands on a non-stuck row, so the SQL composition of the stuck predicate AND the keyset cursor predicate must skip it. Asserts each page returns exactly one stuck row in DESC-by-CreatedAtUtc order with no overlap and all three stuck rows visited. Locks the invariant that post-filtering does not produce under-filled pages with non-null next cursors.
**Resolution (2026-05-28):** Added `SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow` to `tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditActorTests.cs` — drives six rows interleaved as `stuck/non-stuck` × 3 (oldest-first), then issues three page-size-1 stuck-only queries. The cursor between each page deliberately lands on a non-stuck row, so the SQL composition of the stuck predicate AND the keyset cursor predicate must skip it. Asserts each page returns exactly one stuck row in DESC-by-CreatedAtUtc order with no overlap and all three stuck rows visited. Locks the invariant that post-filtering does not produce under-filled pages with non-null next cursors.
**Description**
+31 -31
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.SiteEventLogging` |
| Module | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging` |
| Design doc | `docs/requirements/Component-SiteEventLogging.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -110,7 +110,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs:100-102`, `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:36-55` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs:100-102`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:36-55` |
**Description**
@@ -150,7 +150,7 @@ reliably observes the database shrinking. Regression test
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs:87-105` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs:87-105` |
**Description**
@@ -190,7 +190,7 @@ removed).
| Severity | High |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs:64,90,100,110,114`, `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:36`, `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:34,72` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs:64,90,100,110,114`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs:36`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:34,72` |
**Description**
@@ -233,7 +233,7 @@ concurrently with multiple writer threads.
| Original severity | High (re-triaged down to Low on 2026-05-16 — see Re-triage note) |
| Category | Design-document adherence |
| Status | Won't Fix |
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:313-336`, `src/ScadaLink.SiteEventLogging/EventLogHandlerActor.cs:21-25` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs:313-336`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogHandlerActor.cs:21-25` |
**Description**
@@ -277,14 +277,14 @@ as a plain per-node DI singleton (`AddSiteEventLogging`), so it records to a loc
SQLite file on **every** node, including the standby. That wastes storage on the
standby but does **not** cause the query-returns-nothing symptom the finding
describes, because the query singleton always reads the *active* node's (populated)
database. Gating the writer to the active node would be a `ScadaLink.Host` wiring
database. Gating the writer to the active node would be a `ZB.MOM.WW.ScadaBridge.Host` wiring
change, outside this module's scope, and is a minor optimisation rather than a
correctness defect.
Re-triaged from High to Low and closed as **Won't Fix**: the High-severity
correctness claim does not hold. Any residual cleanup (gate the standby-node writer;
the comment needs no change) can be raised as a fresh Low finding against
`ScadaLink.Host` if desired.
`ZB.MOM.WW.ScadaBridge.Host` if desired.
**Resolution**
@@ -300,7 +300,7 @@ on the active node. No code change made; see the re-triage note above.
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:57-99` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:57-99` |
**Description**
@@ -343,7 +343,7 @@ caller returns in <500 ms while the database is held busy) plus
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:50-52`, `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:65-81` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:50-52`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs:65-81` |
**Description**
@@ -380,7 +380,7 @@ tests `Schema_HasIndexOnSeverity` and `SeverityFilteredQuery_UsesIndex_NotFullSc
| Severity | Medium (partially re-triaged 2026-05-16 — see Re-triage note) |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs:21-30`, `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:20-28`, `src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs:10-23` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs:21-30`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs:20-28`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ServiceCollectionExtensions.cs:10-23` |
**Description**
@@ -426,7 +426,7 @@ three services still share one connection/lock. Regression tests
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:92-95` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:92-95` |
**Description**
@@ -463,7 +463,7 @@ Regression test `LogEventAsync_FaultsTask_AndCountsFailure_OnWriteError`.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/ISiteEventLogger.cs:8-10`, `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:57` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ISiteEventLogger.cs:8-10`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:57` |
**Description**
@@ -510,7 +510,7 @@ tests `LogEventAsync_DoesNotBlockCaller_WhenWriteIsSlow` and
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.SiteEventLogging.Tests/` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/` |
**Description**
@@ -558,7 +558,7 @@ SiteEventLogging-001/-002/-003 were resolved
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs:18-22` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ServiceCollectionExtensions.cs:18-22` |
**Description**
@@ -579,7 +579,7 @@ the misleading comment.
Resolved 2026-05-16 (commit `pending`): confirmed dead code — a repo-wide search
found zero callers of `AddSiteEventLoggingActors`, and `EventLogHandlerActor` is in
fact wired up directly in `ScadaLink.Host/Actors/AkkaHostedService.cs` as a cluster
fact wired up directly in `ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs` as a cluster
singleton (it must be created inside the `ActorSystem` with a resolved
`IEventLogQueryService`, which a `IServiceCollection` extension cannot do). The empty
placeholder method and its stale "Phase 4+" comment were deleted, and a short
@@ -595,7 +595,7 @@ full module suite still passing.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:160-166,193-197` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:160-166,193-197` |
**Description**
@@ -652,7 +652,7 @@ silent-success behaviour and was replaced).
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:79-83` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs:79-83` |
**Description**
@@ -693,7 +693,7 @@ keyword is matched as a literal substring as the design intends. Regression test
| Severity | Low |
| Category | Performance & resource management |
| Status | Won't Fix |
| Location | `src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs:34-48` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs:34-48` |
**Description**
@@ -754,7 +754,7 @@ background scheduler).
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:58-63` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:58-63` |
**Resolution (2026-05-28):** Switched `SiteEventLogger._writeQueue` to
`Channel.CreateBounded<PendingEvent>` with `FullMode = BoundedChannelFullMode.DropOldest`.
@@ -777,7 +777,7 @@ from an alarm storm / script failure loop — drives the queue arbitrarily large
Every queued `PendingEvent` retains its `TaskCompletionSource` and its payload
strings, so there is no upper bound on how much memory the recorder can hold.
The sister centralized-audit component `ScadaLink.AuditLog/Site/SqliteAuditWriter.cs`
The sister centralized-audit component `ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs`
addresses the same hot-path-writer problem with
`Channel.CreateBounded<...>(new BoundedChannelOptions(_options.ChannelCapacity) { ..., FullMode = BoundedChannelFullMode.Wait })`,
giving back-pressure to producers. Site event logging picked the riskier choice for
@@ -803,7 +803,7 @@ chosen policy on `ISiteEventLogger.LogEventAsync`.
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:67-77`, `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:159`, `src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs:72-78` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs:67-77`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:159`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs:72-78` |
**Resolution** — `EventLogQueryService.ExecuteQuery` now calls `.ToUniversalTime()` on `request.From`/`request.To` before `ToString("o")`, so the produced ISO 8601 string always ends in `+00:00` and lexicographically matches the UTC timestamps written by `SiteEventLogger`. `EventLogPurgeService.PurgeByRetention` was also made defensive with an explicit `.ToUniversalTime()` on the cutoff. A regression test (`Query_FiltersByTimeRange_HandlesNonUtcOffset`) constructs a `+05:00` `DateTimeOffset` and asserts the matching UTC-stored events are returned and out-of-range ones are excluded.
@@ -854,7 +854,7 @@ lexicographic-comparison hazard structurally.
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:55`, `src/ScadaLink.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs:18` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs:55`, `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs:18` |
**Resolution (2026-05-28):** `EventLogQueryService.ExecuteQuery` now clamps
`pageSize` to new `SiteEventLogOptions.MaxQueryPageSize` (default 500, matching
@@ -902,9 +902,9 @@ refused.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:67-71,225-226` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:67-71,225-226` |
**Resolution (2026-05-28):** Took option (a)-via-(b) from the recommendation. Softened the `SiteEventLogger.FailedWriteCount` XML doc to describe the actual state ("Available for future Health Monitoring integration — the counter is correct and observable, but the central health-metric pipeline does not yet poll it"), removing the misleading "Health Monitoring can detect a logging outage" claim. The Health Monitoring wiring is left as a tracked follow-up (it requires a `ScadaLink.HealthMonitoring` source change that belongs in a different batch). Promoted `FailedWriteCount { get; }` onto `ISiteEventLogger` so the eventual Health consumer reads it through the interface without a concrete-type downcast. No behaviour change — pure documentation + interface-surface tidy-up; `SiteEventLogger` already exposed the property publicly, and no test fakes/mocks of `ISiteEventLogger` exist in the repo (grep confirms only `SiteEventLogger` implements it), so the interface addition is non-breaking. Existing 59 SiteEventLogging tests remain green.
**Resolution (2026-05-28):** Took option (a)-via-(b) from the recommendation. Softened the `SiteEventLogger.FailedWriteCount` XML doc to describe the actual state ("Available for future Health Monitoring integration — the counter is correct and observable, but the central health-metric pipeline does not yet poll it"), removing the misleading "Health Monitoring can detect a logging outage" claim. The Health Monitoring wiring is left as a tracked follow-up (it requires a `ZB.MOM.WW.ScadaBridge.HealthMonitoring` source change that belongs in a different batch). Promoted `FailedWriteCount { get; }` onto `ISiteEventLogger` so the eventual Health consumer reads it through the interface without a concrete-type downcast. No behaviour change — pure documentation + interface-surface tidy-up; `SiteEventLogger` already exposed the property publicly, and no test fakes/mocks of `ISiteEventLogger` exist in the repo (grep confirms only `SiteEventLogger` implements it), so the interface addition is non-breaking. Existing 59 SiteEventLogging tests remain green.
**Description**
@@ -913,7 +913,7 @@ XML doc statement "Surfaced so Health Monitoring can detect a logging outage
instead of relying on a local log line nobody is watching." The implementation is
correct (`Interlocked.Increment` on write failure, `Interlocked.Read` getter), but
a repo-wide search shows **no** caller anywhere in `src/` reads the property —
neither `ScadaLink.HealthMonitoring`, the central health collector, nor the host's
neither `ZB.MOM.WW.ScadaBridge.HealthMonitoring`, the central health collector, nor the host's
`/health` endpoint. The metric is dead-letter: a logging outage still goes
unnoticed in production, contradicting the original finding's resolution claim.
@@ -938,7 +938,7 @@ file a tracking item for the wiring. The current doc claim is misleading.
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogPurgeService.cs:57-95`, `src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs:30-39`, `docs/requirements/Component-SiteEventLogging.md:45` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs:57-95`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ServiceCollectionExtensions.cs:30-39`, `docs/requirements/Component-SiteEventLogging.md:45` |
**Description**
@@ -989,7 +989,7 @@ SiteEventLogging.Tests).
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:144-156`, `src/ScadaLink.SiteEventLogging/ISiteEventLogger.cs:14-15` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:144-156`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ISiteEventLogger.cs:14-15` |
**Resolution (2026-05-28):** `LogEventAsync` now validates `severity` against
the closed set `{Info, Warning, Error}` (case-sensitive, matches SQLite
@@ -1033,7 +1033,7 @@ case-insensitive. Update the XML doc to match the enforced contract.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:138` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs:138` |
**Resolution (2026-05-28):** `EventLogQueryService.ExecuteQuery` now parses the
stored ISO 8601 timestamp with `CultureInfo.InvariantCulture` plus
@@ -1077,14 +1077,14 @@ avoid all string-parsing.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:52` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs:52` |
**Resolution (2026-05-28):** Dropped `Cache=Shared` from the default connection
string in `SiteEventLogger`'s ctor — the logger owns exactly one
`SqliteConnection` serialised under `_writeLock`, so the cross-connection
shared-cache mode is dormant. The `connectionStringOverride` ctor parameter
still lets a test path inject `Cache=Shared` explicitly when needed. No test
relied on the default option (grep across `tests/ScadaLink.SiteEventLogging.Tests`
relied on the default option (grep across `tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests`
finds zero references). Behaviour unchanged for production callers.
**Description**
@@ -1118,7 +1118,7 @@ and locking_mode review that should accompany it.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs:282-308` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/EventLogPurgeServiceTests.cs:282-308` |
**Resolution (2026-05-28):** Promoted the test-local `bool stop` to a `volatile bool _stop` field on `EventLogPurgeServiceTests` (with a doc-comment cross-referencing this finding). Every writer thread now observes the main thread's `_stop = true` flip without relying on JIT/runtime quirks across the `await _eventLogger.LogEventAsync` boundary, so the regression test for SiteEventLogging-003 can no longer hang past xUnit's per-test timeout in release builds. `CancellationTokenSource` was considered (canonical pattern) but `volatile bool` is the minimal-diff fix consistent with the existing structure.
+29 -29
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.SiteRuntime` |
| Module | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime` |
| Design doc | `docs/requirements/Component-SiteRuntime.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -31,7 +31,7 @@ actor, and the repositories are untested.
#### Re-review 2026-05-17 (commit `39d737e`)
The module was re-reviewed at commit `39d737e`. No source under
`src/ScadaLink.SiteRuntime` has changed since the previous review at `9c60592`
`src/ZB.MOM.WW.ScadaBridge.SiteRuntime` has changed since the previous review at `9c60592`
(the only intervening commits are code-review documentation updates), so all of
SiteRuntime-001..013, 015, 016 remain Resolved and SiteRuntime-014 remains
Deferred — its Deferred justification (a trigger-evaluation concurrency design
@@ -116,7 +116,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs:106`, `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:204` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs:106`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:204` |
**Description**
@@ -165,7 +165,7 @@ synchronously.
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:632` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:632` |
**Description**
@@ -202,7 +202,7 @@ optimistic `true`.
| Severity | High |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:222` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:222` |
**Description**
@@ -243,7 +243,7 @@ longer drifts (this additionally addresses the root cause behind SiteRuntime-004
| Severity | Medium — re-triaged: already fixed by the SiteRuntime-003 resolution. |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs` (`ApplyDeployment`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`ApplyDeployment`) |
**Description**
@@ -283,7 +283,7 @@ Resolved.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs` (`ApplyDeployment`, `HandleDeployPersistenceResult`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`ApplyDeployment`, `HandleDeployPersistenceResult`) |
**Description**
@@ -322,7 +322,7 @@ and `Deploy_Success_ReportsSuccessAndPersistsConfig`.
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs`, `src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs` |
**Description**
@@ -362,7 +362,7 @@ the repository's connection path end-to-end.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Repositories/SiteExternalSystemRepository.cs`, `src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs`, `src/ScadaLink.SiteRuntime/Repositories/SyntheticId.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteExternalSystemRepository.cs`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SyntheticId.cs` |
**Description**
@@ -404,7 +404,7 @@ simulate a process restart and confirm by-ID lookups still resolve.
| Severity | Medium |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs` (`HandleStartupConfigsLoaded`, `LoadSharedScriptsFromStorage`, `HandleSharedScriptsLoaded`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`HandleStartupConfigsLoaded`, `LoadSharedScriptsFromStorage`, `HandleSharedScriptsLoaded`) |
**Description**
@@ -448,7 +448,7 @@ present).
| Severity | Medium |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs`, `src/ScadaLink.SiteRuntime/Actors/AlarmExecutionActor.cs`, `src/ScadaLink.SiteRuntime/Scripts/ScriptExecutionScheduler.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptExecutionActor.cs`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptExecutionScheduler.cs` |
**Description**
@@ -492,7 +492,7 @@ dedicated dispatcher" comments were removed. Regression tests:
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs` (`EnsureDclConnections`, `ComputeConnectionConfigHash`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs` (`EnsureDclConnections`, `ComputeConnectionConfigHash`) |
**Description**
@@ -531,7 +531,7 @@ and `EnsureDclConnections_UnchangedConfig_DoesNotReissueCreateCommand`.
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs` (`ValidateTrustModel`) |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs` (`ValidateTrustModel`) |
**Description**
@@ -586,7 +586,7 @@ literal/identifier non-detection, allowed-exception resolution); all 39 existing
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs:28` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs:28` |
**Description**
@@ -627,7 +627,7 @@ variants. No behavioural change — this is a documentation finding; existing
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:414` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:414` |
**Description**
@@ -668,7 +668,7 @@ behaviour to regression-test, so no new test was added; the existing
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Deferred |
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:389` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs:389` |
**Description**
@@ -719,7 +719,7 @@ an out-of-scope or messaging-contract-changing fix.
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:746` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:746` |
**Description**
@@ -759,7 +759,7 @@ pass after the fix.
| Severity | Low |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.SiteRuntime.Tests/` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/` |
**Description**
@@ -805,7 +805,7 @@ green.
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:625`, `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:675`, `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:83`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:93` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:625`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:675`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs:83`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs:93` |
**Description**
@@ -866,7 +866,7 @@ test project).
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs:17` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptExecutionActor.cs:17` |
**Description**
@@ -907,7 +907,7 @@ regression test was added; the existing suite continues to pass.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:106`, `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:113` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:106`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:113` |
**Description**
@@ -955,7 +955,7 @@ Instance Actor produces no `InstanceLifecycleResponse` for either command
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:285`, `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:971` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:285`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:971` |
**Resolution** — added a name → terminating-actor-ref shadow
(`_terminatingActorsByName`) populated when `HandleDeploy` stops the
@@ -1039,7 +1039,7 @@ be gated on "no instance with this name is currently terminating".
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:931` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:931` |
**Resolution (2026-05-28):** Took the "refactor `EnsureDclConnections` into a shared field-based helper" path. Extracted a new `EnsureDclConnection(name, protocol, primaryJson, backupJson, failoverRetryCount)` method that owns the hash-cache check and the `CreateConnectionCommand` Tell — both the existing inline `EnsureDclConnections(configJson)` and the new artifact path now drive through it. `ComputeConnectionConfigHash` got a field-based overload so the artifact path (which carries data directly on `DataConnectionArtifact`) reuses the same hash logic as the `ConnectionConfig`-based inline path. To keep `_createdConnections` mutation actor-thread-confined (the artifact-deploy persistence runs inside a `Task.Run`), the off-thread persistence dispatches a new internal `ApplyArtifactDataConnectionsToDcl` message back to `Self` after the SQLite writes; the actor-thread handler then iterates and invokes `EnsureDclConnection`. The DCL only sees `CreateConnectionCommand` (no `Update`/`Delete` messages exist in the codebase, and `CreateConnectionCommand` is treated as upsert-by-name — same shape as the inline-config path). Build clean; 302 SiteRuntime tests green (the existing `EnsureDclConnections_ConnectionConfigChanged_ReissuesCreateCommand` regression test still passes through the refactored shared helper).
@@ -1091,7 +1091,7 @@ and artifact paths can drive through it.
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs:138` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbCommand.cs:138` |
**Resolution (2026-05-28):** Took the recommended "expose a proper API surface" path (the SiteRuntime-006 precedent). Added an `internal DbConnection Inner => _inner;` accessor to `AuditingDbConnection`; both classes are `internal sealed` in the same assembly, so the accessor stays out of the public API. The `AuditingDbCommand.DbConnection` setter now unwraps an `AuditingDbConnection` via `auditing.Inner` instead of `Type.GetField("_inner", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(...)`. No reflection, no `!.` null-forgiveness hiding a runtime crash, no static-analyzer/IL-trim noise — and the same module that enforces "no `System.Reflection` in scripts" no longer reflects internally. The getter's `_wrappingConnection ?? _inner.Connection` fallback was left as-is; addressing the `CreateDbCommand()` round-trip concern is a separate behavioural decision (the finding marked it secondary). Build clean; 302 SiteRuntime tests green.
@@ -1154,7 +1154,7 @@ the parent connection inside `AuditingDbConnection.CreateDbCommand`).
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:446`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:340`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:356`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:444` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs:446`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs:340`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs:356`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs:444` |
**Resolution (2026-05-28):** All four call sites
(`ScriptActor.EvaluateCondition`, `AlarmActor.EvaluateRangeViolation`,
@@ -1209,7 +1209,7 @@ on the host's regional settings.
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs:39`, `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs:360` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingStore.cs:39`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingStore.cs:360` |
**Resolution** — split reads from writes: the single owned
`_writeConnection` + `_writeGate` still serialises writers, but
@@ -1270,7 +1270,7 @@ into a sync path that calls `_connection.Dispose()` directly.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:223`, `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:246` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:223`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs:246` |
**Resolution (2026-05-28):** `HandleSetStaticAttribute` now rejects writes
whose `command.AttributeName` does not resolve against
@@ -1334,7 +1334,7 @@ failure response).
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:10`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:13`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:15`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:17`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:19`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:25`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:28`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:30`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:32`, `src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs:34` |
| Location | `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:10`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:13`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:15`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:17`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:19`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:25`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:28`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:30`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:32`, `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Messages/ReplicationMessages.cs:34` |
**Description**
@@ -1355,7 +1355,7 @@ relied on by the `fixdocs` workflow will flag every record as missing docs.
Add a `<summary>` per record naming the direction (outbound → peer / inbound
from peer) and what the operation replicates, and `<param>` docs for each
record parameter. Mirror the precedent in
`src/ScadaLink.Commons/Messages/.../*.cs`. While there, consider sealing the
`src/ZB.MOM.WW.ScadaBridge.Commons/Messages/.../*.cs`. While there, consider sealing the
inbound vs outbound split with a marker base type (currently they're just
named conventionally) so `Receive<ReplicateXxx>` vs `Receive<ApplyXxx>` is
expressed at the type level — but that's optional and out of scope for a
+40 -40
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.StoreAndForward` |
| Module | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward` |
| Design doc | `docs/requirements/Component-StoreAndForward.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -89,7 +89,7 @@ concurrent discard / sweep delete), re-introducing the StoreAndForward-016 stand
divergence in that corner. `StoreAndForward-021` (Medium) is a design-doc-vs-code drift
that should be reconciled in the doc: the **operation tracking table** is documented
inside Component-StoreAndForward.md as a S&F responsibility (lines 21, 49, 7787, 108,
114), but the actual `OperationTrackingStore` lives in `src/ScadaLink.SiteRuntime/
114), but the actual `OperationTrackingStore` lives in `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/
Tracking/` and is not consumed by S&F at all — the brief's own note flags this. The
design doc should be updated to point at SiteRuntime, or the store moved to
StoreAndForward.
@@ -122,7 +122,7 @@ shutdown sequence (the DI container) can then NRE through the still-running swee
| 6 | Performance & resource management | ☑ | No new findings — the connection-per-call documented trade-off and pooled `OpenAsync` remain acceptable. |
| 7 | Design-document adherence | ☑ | Operation Tracking Table documented in StoreAndForward but actually lives in SiteRuntime (021); notification non-parking guarantee broken by 018 + 019. |
| 8 | Code organization & conventions | ☑ | `IStoreAndForwardSiteContext` silently defaults `SiteId` to empty (023) — a configuration hole rather than an entity placement issue. |
| 9 | Testing coverage | ☑ | The seven new findings have no regression tests in `tests/ScadaLink.StoreAndForward.Tests/` — particularly the notification-doesn't-park invariant (018, 019), the requeue-after-reload-null replication gap (020), and the stop-during-sweep behaviour (024). |
| 9 | Testing coverage | ☑ | The seven new findings have no regression tests in `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/` — particularly the notification-doesn't-park invariant (018, 019), the requeue-after-reload-null replication gap (020), and the stop-during-sweep behaviour (024). |
| 10 | Documentation & comments | ☑ | `CachedCallAttemptOutcome.ParkedMaxRetries` XML doc says "S&F semantics" but the code applies it to notifications too if 018/019 fire — minor drift, captured under 018. The `TrackedOperationId.TryParse` silent-skip behaviour in `NotifyCachedCallObserverAsync` is documented in the source but not on the public observer contract (022). |
## Checklist coverage
@@ -149,7 +149,7 @@ shutdown sequence (the DI container) can then NRE through the still-running swee
| Severity | Critical |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/ReplicationService.cs:40`, `:53`, `:66`; `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:155`, `:212`, `:222`, `:236` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ReplicationService.cs:40`, `:53`, `:66`; `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:155`, `:212`, `:222`, `:236` |
**Description**
@@ -193,7 +193,7 @@ commit whose message references `StoreAndForward-001`.
| Original severity | High (re-triaged down to Low on 2026-05-16 — see Re-triage note) |
| Category | Error handling & resilience |
| Status | Deferred |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:162`, `:201` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:162`, `:201` |
**Description**
@@ -221,7 +221,7 @@ transient condition with bounded logging rather than a permanent no-op.
The finding's central factual claim — *"No caller in the codebase ever calls
`RegisterDeliveryHandler`"* and therefore *"every buffered message lands in this dead
state"* — is **no longer true at the reviewed code**. `ScadaLink.Host`
state"* — is **no longer true at the reviewed code**. `ZB.MOM.WW.ScadaBridge.Host`
(`AkkaHostedService.RegisterSiteActors`, `AkkaHostedService.cs:353-379`) registers all
three delivery handlers (`ExternalSystem`, `CachedDbWrite`, `Notification`) at site
startup, immediately after `StoreAndForwardService.StartAsync()`. The finding was
@@ -249,7 +249,7 @@ should be made deliberately rather than forced here.
_Deferred 2026-05-16 (re-triaged High → Low). Verified again against the source in this
pass: the finding's premise (no `RegisterDeliveryHandler` caller anywhere) is stale —
`ScadaLink.Host` wires all three handlers at site startup — so the High-severity
`ZB.MOM.WW.ScadaBridge.Host` wires all three handlers at site startup — so the High-severity
"engine cannot deliver anything" outcome no longer occurs. The residual gap (a message
enqueued for a category that genuinely has no handler is buffered then skipped forever)
is real but minor. The prescribed fix — making `EnqueueAsync` reject when no handler is
@@ -266,7 +266,7 @@ pending that decision rather than forced here._
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:153`, `:229`, `:233` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:153`, `:229`, `:233` |
**Description**
@@ -316,7 +316,7 @@ corrected semantics.
| Severity | Medium |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:38`, `:60` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:38`, `:60` |
**Description**
@@ -355,7 +355,7 @@ Documentation-only change — no behavioural code touched, so no regression test
| Original severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:184`, `:266`, `:280` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:184`, `:266`, `:280` |
**Description**
@@ -423,7 +423,7 @@ other writer's `RetryCount`).
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:166`, `:175` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs:166`, `:175` |
**Description**
@@ -466,7 +466,7 @@ finding's recommendation.
| Severity | Low |
| Category | Akka.NET conventions |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs:34`, `:68`, `:87` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ParkedMessageHandlerActor.cs:34`, `:68`, `:87` |
**Description**
@@ -511,7 +511,7 @@ is a concrete non-virtual type with no failure-injection seam.
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:28`, `:61`, `:93`, `:117`, `:144`, `:162`, `:199`, `:221`, `:237`, `:267`, `:285`, `:305`, `:319` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs:28`, `:61`, `:93`, `:117`, `:144`, `:162`, `:199`, `:221`, `:237`, `:267`, `:285`, `:305`, `:319` |
**Description**
@@ -550,7 +550,7 @@ touched, so no regression test (the connection-pool reliance is not test-observa
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:46`, `:309` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:46`, `:309` |
**Description**
@@ -596,7 +596,7 @@ with `WasBuffered == false` and an empty buffer) and
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:203`, `:101` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs:203`, `:101` |
**Description**
@@ -637,7 +637,7 @@ cleared, message excluded from the retry-due set) and passes post-fix.
| Severity | Low |
| Category | Design-document adherence |
| Status | Deferred |
| Location | `src/ScadaLink.Commons/Types/Enums/StoreAndForwardMessageStatus.cs:9`; `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:219`, `:235` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/StoreAndForwardMessageStatus.cs:9`; `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:219`, `:235` |
**Description**
@@ -663,8 +663,8 @@ StoreAndForward module only ever assigns `Pending` and `Parked`, and `InFlight`
`Delivered` are never assigned anywhere (delivered messages are deleted, not marked).
The design doc's `retrying` state is unmodelled. Both options the recommendation offers
— (a) drop the unused `InFlight`/`Delivered` members, or (b) add a `Retrying` member —
require editing `StoreAndForwardMessageStatus.cs`, which lives in `src/ScadaLink.Commons`
(outside this review's edit scope: only `src/ScadaLink.StoreAndForward/**` may be
require editing `StoreAndForwardMessageStatus.cs`, which lives in `src/ZB.MOM.WW.ScadaBridge.Commons`
(outside this review's edit scope: only `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/**` may be
changed). The enum is also referenced by IntegrationTests and HealthMonitoring tests, so
removing members is a cross-module change. The defect is real but cannot be resolved
in-module; **Deferred** to a change that owns the Commons enum and the design doc
@@ -677,7 +677,7 @@ together._
| Severity | Low |
| Category | Code organization & conventions |
| Status | Deferred |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs:9` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardMessage.cs:9` |
**Description**
@@ -706,8 +706,8 @@ mapping to `sf_messages` and is also carried across Akka remoting inside
component assembly rather than the Commons `Entities`/`Messages` hierarchy. The
recommendation's primary remedy — moving `StoreAndForwardMessage` (and
`ReplicationOperation`) into Commons — crosses module boundaries (it would add a type to
`src/ScadaLink.Commons`, outside this review's edit scope of
`src/ScadaLink.StoreAndForward/**`, and change every referencing module). The alternative
`src/ZB.MOM.WW.ScadaBridge.Commons`, outside this review's edit scope of
`src/ZB.MOM.WW.ScadaBridge.StoreAndForward/**`, and change every referencing module). The alternative
"separate replication DTO" still leaves the persistence entity in the component, so it
does not actually resolve the finding's core concern (entity placement / contract-
evolution governance). Resolving this is a deliberate code-organisation decision that
@@ -721,7 +721,7 @@ cross-module follow-up._
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.StoreAndForward.Tests/` (whole directory); `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:101`; `src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/` (whole directory); `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs:101`; `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ParkedMessageHandlerActor.cs` |
**Description**
@@ -768,7 +768,7 @@ These are coverage-gap tests over already-correct code, so they pass on first ru
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:26` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardStorage.cs:26` |
**Found 2026-05-16** while verifying the store-and-forward fixes — this defect was
not part of the original baseline review.
@@ -806,7 +806,7 @@ all six `SiteActorPathTests` now pass. Fixed by the commit whose message referen
| Severity | Medium |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:114``:130`, `:285` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:114``:130`, `:285` |
**Description**
@@ -873,7 +873,7 @@ test-observable so no regression test was added.
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:339``:362`; `src/ScadaLink.StoreAndForward/ReplicationService.cs:131``:136` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:339``:362`; `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ReplicationService.cs:131``:136` |
**Description**
@@ -942,7 +942,7 @@ requeued row and calls `_replication?.ReplicateRequeue`, and the standby's
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:344`, `:358` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:344`, `:358` |
**Description**
@@ -992,7 +992,7 @@ the StoreAndForward-016 replication) — and pass it to `RaiseActivity` (falling
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/NotificationForwarder.cs:62``:69`, `:105``:122`; `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:369``:397` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/NotificationForwarder.cs:62``:69`, `:105``:122`; `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:369``:397` |
**Resolution** — `NotificationForwarder.DeliverAsync` now discards a corrupt
buffered payload instead of returning false. The corrupt path logs a Warning
@@ -1053,7 +1053,7 @@ parking exception specifically for notifications, and revise the resolved
StoreAndForward-017 wording; (b) treat `JsonException` as transient (would retry-forever
on a corrupt payload — bad); (c) introduce a per-category park-allowed flag on the
engine and gate the retry-path park behind it for the Notification category.
Add a regression test in `tests/ScadaLink.StoreAndForward.Tests/NotificationForwarderTests.
Add a regression test in `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/NotificationForwarderTests.
cs` asserting that a corrupt-payload notification reaches a terminal **non-Parked**
state — today the corrupt-payload behaviour is uncovered.
@@ -1068,7 +1068,7 @@ _Unresolved._
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:229`, `:407``:437`; `src/ScadaLink.StoreAndForward/StoreAndForwardOptions.cs:18`; `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs:1773``:1778`; `src/ScadaLink.NotificationService/NotificationDeliveryService.cs:149``:156` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:229`, `:407``:437`; `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardOptions.cs:18`; `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs:1773``:1778`; `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationDeliveryService.cs:149``:156` |
**Description**
@@ -1112,7 +1112,7 @@ parking violation under the engine's normal max-retries policy.
Make the notification enqueue paths pass `maxRetries: 0` so the documented "no limit /
never parked" semantics apply, and guard against regression by adding an integration
test in `tests/ScadaLink.StoreAndForward.Tests/NotificationForwarderTests.cs` that runs
test in `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/NotificationForwarderTests.cs` that runs
a sweep many more times than `DefaultMaxRetries` against an always-failing handler and
asserts the buffered notification's status stays `Pending` (not `Parked`). A cleaner
alternative is to special-case the `Notification` category inside
@@ -1135,7 +1135,7 @@ applies uniformly (including to notifications) and that `maxRetries: 0` is the e
escape hatch for callers that need unbounded retry. Added a `StoreAndForward-019` block
to `StoreAndForwardOptions.DefaultMaxRetries`'s XML doc explaining the same invariant.
No behavioural code change — existing tests (104 in
`ScadaLink.StoreAndForward.Tests`) continue to pass.
`ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests`) continue to pass.
### StoreAndForward-020 — `RetryParkedMessageAsync` skips standby replication when the message is deleted between local update and re-load
@@ -1144,7 +1144,7 @@ No behavioural code change — existing tests (104 in
| Severity | Medium |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:599``:616` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:599``:616` |
**Description**
@@ -1239,7 +1239,7 @@ the activity log uses the captured `Category` directly.
| Severity | Medium |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `docs/requirements/Component-StoreAndForward.md:21`, `:49``:51`, `:77``:87`, `:108`, `:114`; `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs:37`; `src/ScadaLink.StoreAndForward/` (whole module) |
| Location | `docs/requirements/Component-StoreAndForward.md:21`, `:49``:51`, `:77``:87`, `:108`, `:114`; `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingStore.cs:37`; `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/` (whole module) |
**Description**
@@ -1257,12 +1257,12 @@ this component:
each site node holds a **site-local operation tracking table** in SQLite. … Each row
records the operation kind (`TrackedOperationKind`) …"
The actual implementation lives outside this module: `src/ScadaLink.SiteRuntime/
The actual implementation lives outside this module: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/
Tracking/OperationTrackingStore.cs` (and `IOperationTrackingStore`, `OperationTrackingOptions`).
The StoreAndForward project contains no references to the tracking store, owns no
`operation_tracking` table, and `StoreAndForwardService.NotifyCachedCallObserverAsync`
is only a hook handing telemetry context to an `ICachedCallLifecycleObserver` — the
audit bridge wired in `ScadaLink.AuditLog`. The S&F module is **not** the table's
audit bridge wired in `ZB.MOM.WW.ScadaBridge.AuditLog`. The S&F module is **not** the table's
owner; SiteRuntime is.
This is a real design-doc drift, not a code defect, and is flagged explicitly in the
@@ -1271,7 +1271,7 @@ discussion of the lifecycle — "immediate success writes a terminal Delivered t
row directly here", "operator discard sets terminal `Discarded`", "central never
mutates the mirror row directly" — places coordination responsibilities on the wrong
component. A reader looking for the source of truth for `Tracking.Status(id)` would
read `Component-StoreAndForward.md` and search `src/ScadaLink.StoreAndForward/` in
read `Component-StoreAndForward.md` and search `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/` in
vain. The doc also lists Site Call Audit / Audit Log telemetry-emission as a S&F
responsibility (line 22), but the emission actually happens via the `AuditLog` site
component subscribing to `ICachedCallLifecycleObserver`.
@@ -1313,7 +1313,7 @@ not owned here.
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:484``:515` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:484``:515` |
**Description**
@@ -1332,7 +1332,7 @@ TrackedOperationId threaded in)", but the documented contract is broken in two w
so a misconfigured caller bypasses the audit hot path with zero feedback.
2. **The contract is hidden in field-level XML.** The `ICachedCallLifecycleObserver`
public interface contract (defined in `ScadaLink.Commons`) does not document that
public interface contract (defined in `ZB.MOM.WW.ScadaBridge.Commons`) does not document that
the observer will be silently skipped when the underlying S&F message id is not a
GUID. A consumer reading the interface contract reasonably expects every cached-call
attempt to surface — the audit pipeline depends on it. The silent-drop is an
@@ -1377,7 +1377,7 @@ is on `_observer.Notifications` being empty, which is unchanged.
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/ServiceCollectionExtensions.cs:43``:53`; `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:99`, `:524` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/ServiceCollectionExtensions.cs:43``:53`; `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:99`, `:524` |
**Description**
@@ -1436,7 +1436,7 @@ normalisation is a no-op there.
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:122``:127`, `:136``:143`, `:303``:329` |
| Location | `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardService.cs:122``:127`, `:136``:143`, `:303``:329` |
**Resolution (2026-05-28):** the timer callback now captures the sweep task
into a `_sweepTask` field via `Volatile.Write`, and `StopAsync` disposes the
+28 -28
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.TemplateEngine` |
| Module | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine` |
| Design doc | `docs/requirements/Component-TemplateEngine.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -128,7 +128,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:211`, `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:535`, `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:609` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:211`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:535`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:609` |
**Description**
@@ -167,7 +167,7 @@ Regression tests: `Flatten_ThreeLevelComposition_AttributesAlarmsScriptsAllResol
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:799` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:799` |
**Description**
@@ -195,7 +195,7 @@ Resolved 2026-05-16 (commit `<pending>`): implemented the per-slot alarm
override mechanism as a coordinated `Commons` + `ConfigurationDatabase` +
`TemplateEngine` change, mirroring the existing attribute/script override
design. Added `IsInherited` / `LockedInDerived` to the `TemplateAlarm` POCO
(`ScadaLink.Commons`) and an EF migration `AddDerivedAlarmFields` adding two
(`ZB.MOM.WW.ScadaBridge.Commons`) and an EF migration `AddDerivedAlarmFields` adding two
`bit NOT NULL DEFAULT 0` columns to `TemplateAlarms`. `BuildDerivedTemplate`
now copies base alarms as `IsInherited = true` placeholder rows.
`FlatteningService.ResolveInheritedAlarms` skips `IsInherited` placeholder
@@ -218,7 +218,7 @@ now rejects a derived override of a `LockedInDerived` base alarm.
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:285` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:285` |
**Description**
@@ -260,7 +260,7 @@ tests: `UpdateAttribute_UnlockedAttribute_DataTypeChangeRejected`,
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:695` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:695` |
**Description**
@@ -300,7 +300,7 @@ all see the reference. Regression tests:
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:56` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:56` |
**Description**
@@ -339,7 +339,7 @@ Regression test: `CreateTemplate_WithParent_DoesNotRunDeadCollisionQuery`.
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs:21`, `src/ScadaLink.TemplateEngine/Validation/ValidationService.cs:318` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs:21`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs:318` |
**Description**
@@ -386,7 +386,7 @@ doc, and the scan is explicitly labelled advisory. Regression tests:
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs:54`, `src/ScadaLink.TemplateEngine/SharedScriptService.cs:124` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs:54`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/SharedScriptService.cs:124` |
**Description**
@@ -429,7 +429,7 @@ genuine mismatches are still caught. Regression tests in `ScriptCompilerTests`
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Services/InstanceService.cs:178` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs:178` |
**Description**
@@ -468,7 +468,7 @@ lock-bypass. Regression tests: `SetAlarmOverride_NonExistentAlarm_ReturnsFailure
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs:75` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateDeletionService.cs:75` |
**Description**
@@ -506,7 +506,7 @@ the `Compositions` navigation, matching how EF's `GetAllTemplatesAsync` loads it
| Severity | Low — re-triaged from Medium: this is a stale XML comment, not a behavioural defect. The code matches the design (last-write-wins); only the doc string was wrong. |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Services/InstanceService.cs:9` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs:9` |
**Description**
@@ -550,7 +550,7 @@ behaviour change; no regression test (documentation-only).
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:136` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs:136` |
**Description**
@@ -592,7 +592,7 @@ test gate instead of silently changing every revision hash.
| Severity | Low |
| Category | Design-document adherence |
| Status | Deferred |
| Location | `src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs:18` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs:18` |
**Description**
@@ -612,13 +612,13 @@ or rename the enum to match the doc if "Integer" is the intended canonical name.
**Re-triage**
Verified against the source: the `DataType` enum is declared in
`src/ScadaLink.Commons/Types/Enums/DataType.cs` (`Boolean, Int32, Float, Double,
`src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs` (`Boolean, Int32, Float, Double,
String, DateTime, Binary`) — **not** in the TemplateEngine module — and is consumed
across modules (`TemplateAttribute` entity, management command contracts). The only
in-module file the finding cites, `SemanticValidator.cs:18`, is confirmed **correct**:
`NumericDataTypes` already hard-codes the real enum names. Both remediation options
in the recommendation therefore land **outside** this module's resolution boundary
(`src/ScadaLink.TemplateEngine/**`): renaming the enum touches `ScadaLink.Commons`
(`src/ZB.MOM.WW.ScadaBridge.TemplateEngine/**`): renaming the enum touches `ZB.MOM.WW.ScadaBridge.Commons`
(and every consumer of `DataType`), and the alternative — updating the design doc —
touches `docs/requirements/Component-TemplateEngine.md`. There is no in-module code
defect to fix. Re-triaged from Open to Deferred: the fix is a one-line design-doc
@@ -630,7 +630,7 @@ String") that must be made by an agent owning the docs / Commons scope.
Deferred 2026-05-16 (no commit): no in-module fix possible — see Re-triage. The
TemplateEngine code is correct as-is. FLAGGED for the docs owner: correct the
Attribute data-type list in `docs/requirements/Component-TemplateEngine.md` to match
`ScadaLink.Commons` `DataType` (`Boolean, Int32, Float, Double, String, DateTime,
`ZB.MOM.WW.ScadaBridge.Commons` `DataType` (`Boolean, Int32, Float, Double, String, DateTime,
Binary`). Renaming the enum is not recommended (cross-module churn for no behavioural
gain); the doc is the authoritative thing to fix.
@@ -641,7 +641,7 @@ gain); the doc is the authoritative thing to fix.
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/CycleDetector.cs:30`, `src/ScadaLink.TemplateEngine/CycleDetector.cs:38` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/CycleDetector.cs:30`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/CycleDetector.cs:38` |
**Description**
@@ -688,7 +688,7 @@ are detected. Regression tests:
| Severity | Low |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:109`, `src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs:27` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:109`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateDeletionService.cs:27` |
**Description**
@@ -731,7 +731,7 @@ verifies all three constraint categories are surfaced together.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:680` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:680` |
**Description**
@@ -791,7 +791,7 @@ mutates. Regression tests:
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:750` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:750` |
**Description**
@@ -844,7 +844,7 @@ resolves against the real parent module. Regression test:
| Severity | High |
| Category | Design-document adherence |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:128`, `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:156`, `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:42`, `src/ScadaLink.TemplateEngine/Flattening/DiffService.cs:110`, `src/ScadaLink.TemplateEngine/Flattening/DiffService.cs:118` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs:128`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs:156`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs:42`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs:110`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs:118` |
**Resolution** — Added `Description` to `HashableAttribute` and `HashableAlarm` (placed alphabetically per the determinism contract) and introduced a `HashableConnection` projection plus a `SortedDictionary<string, HashableConnection> Connections` field on `HashableConfiguration` that captures protocol, primary/backup JSON, and failover retry count for every deployed connection. `DiffService.AttributesEqual` and `AlarmsEqual` now compare `Description`, and a new public `ConnectionsEqual` helper covers connection-endpoint drift so callers can detect the change in the same shape used by the other entity comparators. Regression tests `ComputeHash_AttributeDescriptionEdit_ChangesHash`, `ComputeHash_AlarmDescriptionEdit_ChangesHash`, `ComputeHash_ConnectionEndpointEdit_ChangesHash`, and `ConnectionsEqual_EndpointEdit_ReturnsFalse` lock the behaviour in.
@@ -914,9 +914,9 @@ TemplateEngine-018.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/DiffService.cs:19` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs:19` |
**Resolution (2026-05-28):** Added `DiffService.ComputeConnectionsDiff(oldConfig, newConfig)`, which mirrors the existing attribute/alarm/script `ComputeEntityDiff` shape over the `FlattenedConfiguration.Connections` map and emits `DiffEntry<ConnectionConfig>` Added / Removed / Changed entries keyed by connection name (delegating equality to the existing `ConnectionsEqual` helper). Kept the new diff as a parallel method on `DiffService` rather than extending `ConfigurationDiff` (which lives in `ScadaLink.Commons`, outside this fix's scope); the public-record extension and Central UI plumbing are a paired Commons follow-up. Regression tests: `ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded`, `ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved`, `ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged`, `ComputeConnectionsDiff_IdenticalConnections_NoEntries`.
**Resolution (2026-05-28):** Added `DiffService.ComputeConnectionsDiff(oldConfig, newConfig)`, which mirrors the existing attribute/alarm/script `ComputeEntityDiff` shape over the `FlattenedConfiguration.Connections` map and emits `DiffEntry<ConnectionConfig>` Added / Removed / Changed entries keyed by connection name (delegating equality to the existing `ConnectionsEqual` helper). Kept the new diff as a parallel method on `DiffService` rather than extending `ConfigurationDiff` (which lives in `ZB.MOM.WW.ScadaBridge.Commons`, outside this fix's scope); the public-record extension and Central UI plumbing are a paired Commons follow-up. Regression tests: `ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded`, `ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved`, `ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged`, `ComputeConnectionsDiff_IdenticalConnections_NoEntries`.
**Description**
@@ -957,7 +957,7 @@ _Unresolved._
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateResolver.cs:117`, `src/ScadaLink.TemplateEngine/TemplateResolver.cs:123` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateResolver.cs:117`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateResolver.cs:123` |
**Resolution (2026-05-28):** `BuildInheritanceChain` now walks the parent chain via the `int?` `ParentTemplateId` directly — only a missing (`null`) value means "no parent", so a real template Id of 0 walks the chain like any other node (matching the duplicate-tolerant `BuildLookup` and the TemplateEngine-013 `CycleDetector` fix). Regression tests: `BuildInheritanceChain_RealIdZero_IsTreatedAsParentReferenceNotAsNoParent`, `BuildInheritanceChain_ParentChainThroughIdZero_DoesNotTruncateChainAtZero`, and the end-to-end `ResolveAllMembers_TemplateWithRealIdZero_StillResolvesItsMembers`.
@@ -1019,7 +1019,7 @@ _Unresolved._
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:77`, `src/ScadaLink.TemplateEngine/TemplateService.cs:256`, `src/ScadaLink.TemplateEngine/TemplateService.cs:407`, `src/ScadaLink.TemplateEngine/TemplateService.cs:556`, `src/ScadaLink.TemplateEngine/TemplateService.cs:734`, `src/ScadaLink.TemplateEngine/SharedScriptService.cs:71` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:77`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:256`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:407`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:556`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:734`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/SharedScriptService.cs:71` |
**Resolution (2026-05-28):** Every `Create*` path in `TemplateService` (`CreateTemplateAsync`, `AddAttributeAsync`, `AddAlarmAsync`, `AddScriptAsync`, `AddCompositionAsync`) and `SharedScriptService.CreateSharedScriptAsync` now follows the `InstanceService.CreateInstanceAsync` shape — save the entity first so EF Core populates the auto-generated key, then log the audit row with the real `entity.Id`, then save the audit row. `AddCompositionAsync` already saved the composition row inside `CreateCascadedCompositionAsync` before returning, so only its `LogAsync` call needed to switch from `"0"` to `composition.Id.ToString()`. Regression tests assert the captured audit `entityId` equals the post-save id (not `"0"`): `CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero`, `AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero`, `AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero`, `AddScript_AuditRowCarriesRealScriptIdNotLiteralZero`, and `CreateSharedScript_AuditRowCarriesRealScriptIdNotLiteralZero`.
@@ -1074,7 +1074,7 @@ _Unresolved._
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:173` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:173` |
**Description**
@@ -1131,7 +1131,7 @@ template in a *different* folder is not a collision).
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/LockEnforcer.cs:109`, `src/ScadaLink.TemplateEngine/TemplateService.cs:323`, `src/ScadaLink.TemplateEngine/TemplateService.cs:476`, `src/ScadaLink.TemplateEngine/TemplateService.cs:623` |
| Location | `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/LockEnforcer.cs:109`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:323`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:476`, `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:623` |
**Description**
+17 -17
View File
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.Transport` |
| Module | `src/ZB.MOM.WW.ScadaBridge.Transport` |
| Design doc | `docs/requirements/Component-Transport.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
@@ -54,7 +54,7 @@ entry-count / per-entry decompression cap).
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:844-851` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:844-851` |
**Resolution** — Extended `ApplyTemplatesAsync`'s Overwrite branch with three
new private diff-and-merge helpers (`SyncTemplateAttributesAsync`,
@@ -101,7 +101,7 @@ template whose Scripts / Attributes / Alarms differ.
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:1213-1221` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:1213-1221` |
**Resolution** — Added a private `SyncExternalSystemMethodsAsync` helper to
`BundleImporter` modeled on the T-001 `SyncTemplate*Async` helpers (dictionary-
@@ -137,7 +137,7 @@ methods differ.
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:184-203`, `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs:267-309`, `src/ScadaLink.Commons/Types/Transport/BundleSession.cs:14-16` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:184-203`, `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor.cs:267-309`, `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Transport/BundleSession.cs:14-16` |
**Description**
@@ -174,7 +174,7 @@ _Unresolved._
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/TransportOptions.cs:12`, `docs/requirements/Component-Transport.md` §11 |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs:12`, `docs/requirements/Component-Transport.md` §11 |
**Resolution (2026-05-28):** Added a new `BundleUnlockRateLimiter` class (in-memory, per-key sliding-window counter via `ConcurrentDictionary<string, Queue<DateTimeOffset>>` with per-bucket locking), registered as a singleton in `AddTransport`, and wired into `BundleImporter.LoadAsync` before the decrypt attempt — exceeding the per-window cap throws the new `BundleUnlockRateLimitedException` (429-equivalent). The importer keys the limiter on the bundle's `ContentHash` (it has no `IHttpContext` dependency by design); an IP-aware caller can use the limiter's public `TryRegisterAttempt(clientIp, max)` directly for true per-IP keying. `BundleUnlockRateLimiterTests` covers: N attempts allowed and N+1 rejected; full-window expiry releases the entire budget; partial expiry releases only the aged-out slots (sliding window); per-key isolation; argument validation.
@@ -208,12 +208,12 @@ _Unresolved._
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Encryption/BundleSecretEncryptor.cs:31-49`, `src/ScadaLink.Transport/Serialization/ManifestValidator.cs:29-53` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/Encryption/BundleSecretEncryptor.cs:31-49`, `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ManifestValidator.cs:29-53` |
**Description**
AES-GCM is called with no Associated Authenticated Data (AAD). The `manifest`
fields — `SourceEnvironment`, `ExportedBy`, `ScadaLinkVersion`, `Summary`,
fields — `SourceEnvironment`, `ExportedBy`, `ScadaBridgeVersion`, `Summary`,
`Contents`, `CreatedAtUtc`, etc. — are plaintext and only the `ContentHash`
field is checked against the content bytes. An attacker who obtains a bundle
can edit any non-`ContentHash` manifest field (e.g. rewrite the
@@ -244,7 +244,7 @@ _Unresolved._
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Serialization/BundleSerializer.cs:121-156`, `src/ScadaLink.Transport/Import/BundleImporter.cs:132-143` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/BundleSerializer.cs:121-156`, `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:132-143` |
**Description**
@@ -277,7 +277,7 @@ _Unresolved._
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:614-696`, `src/ScadaLink.Transport/Import/BundleSessionStore.cs:67-93` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:614-696`, `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs:67-93` |
**Description**
@@ -313,7 +313,7 @@ _Unresolved._
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:252-272` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:252-272` |
**Resolution (2026-05-28):** Added a bulk `GetTemplatesWithChildrenAsync(IEnumerable<string> names, CancellationToken)` method on `ITemplateEngineRepository` and implemented it on `TemplateEngineRepository` with a single `Where(t => names.Contains(t.Name))` + `Include(Attributes/Alarms/Scripts/Compositions).AsSplitQuery()` round-trip; null / empty / duplicate names are filtered before the IN clause. `BundleImporter.PreviewAsync` now calls the bulk method once with `content.Templates.Select(t => t.Name)` and indexes the result by name, replacing the previous `GetAllTemplatesAsync` scan + per-match `GetTemplateWithChildrenAsync(stub.Id)` round-trip pair. Regression tests: `TemplateEngineRepositoryTests.GetTemplatesWithChildrenAsync_*` (4 tests — bulk fetch, empty input, null enumerable, dedup) and `BundleImporterPreviewTests.PreviewAsync_multiple_templates_with_children_diffs_each_correctly` (asserts the bulk fetch hydrates all three templates' Attributes / Alarms / Scripts so the diff classifies each as Identical).
@@ -346,7 +346,7 @@ _Unresolved._
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:528, 668, 703`, `src/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs` |
| Location | `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:528, 668, 703`, `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Services/AuditCorrelationContext.cs` |
**Resolution (2026-05-28):**
Took option (a). `AuditCorrelationContext` now backs `BundleImportId` with a `static AsyncLocal<Guid?>`,
@@ -389,14 +389,14 @@ _Unresolved._
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs`, `tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs` |
| Location | `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ConflictResolutionTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs` |
**Resolution (2026-05-28):** Re-verified the listed gaps against the current
tree. Each item the finding enumerated has landed in the recent fix commits:
- **Template Overwrite with divergent Attributes / Alarms / Scripts** — covered
by `ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle`
in `tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs`
in `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs`
(added with the Transport-001 fix in commit `e3ca9af`). Asserts the bundle's
child collections fully replace the divergent target shape AND that per-field
audit rows (`TemplateAttributeAdded`/`Updated`/`Deleted`, the alarm and script
@@ -406,10 +406,10 @@ tree. Each item the finding enumerated has landed in the recent fix commits:
same file (Transport-002 fix, commit `e3ca9af`). Mirrors the T-001 shape with
`ExternalSystemMethodAdded`/`Updated`/`Deleted` audit rows.
- **Per-IP unlock-throttle behaviour (Transport-004)** — covered by
`tests/ScadaLink.Transport.Tests/Import/BundleUnlockRateLimiterTests.cs` (12
`tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleUnlockRateLimiterTests.cs` (12
tests: under-limit, at-limit rejection, sliding-window reset, per-key isolation).
- **Failed-apply session retention (Transport-007)** — covered by
`tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs`:
`tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionStoreTests.cs`:
`Get_after_TTL_returns_null_and_evicts`, `EvictExpired_removes_all_past_ttl`,
`UnlockFailures_ExpireOnTtlAndGetReturnsZero`, and
`UnlockFailures_EvictExpired_ClearsStaleEntries` collectively pin the TTL
@@ -460,7 +460,7 @@ Add the missing integration tests above. Most can be modelled after
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `docs/requirements/Component-Transport.md` Import Flow Step 1, `src/ScadaLink.Transport/Import/BundleImporter.cs:124-203` |
| Location | `docs/requirements/Component-Transport.md` Import Flow Step 1, `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs:124-203` |
**Description**
@@ -500,7 +500,7 @@ bundle prompt is surfaced AFTER the manifest+hash check, and that a dedicated
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `docs/requirements/Component-Transport.md` §Audit Trail, `src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs:148` |
| Location | `docs/requirements/Component-Transport.md` §Audit Trail, `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/CentralUiRepository.cs:148` |
**Description**
+2 -2
View File
@@ -8,7 +8,7 @@
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.<Module>` |
| Module | `src/ZB.MOM.WW.ScadaBridge.<Module>` |
| Design doc | `docs/requirements/Component-<Name>.md` |
| Status | Not yet reviewed \| In progress \| Reviewed |
| Last reviewed | YYYY-MM-DD |
@@ -50,7 +50,7 @@ Confirm every category was examined. Record "No issues found" where applicable.
| Severity | Critical \| High \| Medium \| Low |
| Category | <one of the 10 checklist categories> |
| Status | Open \| In Progress \| Resolved \| Won't Fix \| Deferred |
| Location | `src/ScadaLink.<Module>/<File>.cs:<line>` |
| Location | `src/ZB.MOM.WW.ScadaBridge.<Module>/<File>.cs:<line>` |
**Description**
+1 -1
View File
@@ -86,7 +86,7 @@ def build_readme(modules, per_module):
add("# Code Reviews")
add("")
add("Comprehensive, per-module code reviews of the ScadaLink codebase. Each module (one")
add("Comprehensive, per-module code reviews of the ScadaBridge codebase. Each module (one")
add("buildable project under `src/`) has its own folder containing a `findings.md`. This")
add("README is the aggregated index — the single place to see all outstanding work.")
add("")