3a21e77c03
Corrects the Status Board (PLAN-04 24/26, not 0), and adds a Parallelization Map to 00-MASTER-TRACKER plus a per-plan Parallelization block to PLAN-04/05/06/07/08. Derives ready sets from the live .tasks.json blockedBy graphs and the Files metadata, identifies the initiative-wide serialization mutexes (ManagementActor.cs single-writer, EF migration snapshot, Host/Program.cs, ArtifactDeploymentService.cs, SiteStorageService.cs, NotificationOutboxActor.cs, AlarmSummary.razor), and recommends the safe concurrent subagent lanes per plan. Flags PLAN-07 T33 as a dedupe-skip (CLI.Tests already in slnx).
749 lines
56 KiB
Markdown
749 lines
56 KiB
Markdown
# Conventions, Tests & Underdeveloped Areas Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Close the cross-cutting gaps from architecture review 08 — the CLI.Tests slnx blind spot, the ~12 components binding options without startup validation, the vestigial site-side notification surface, the misnamed-thin PerformanceTests project, the docs-code drift owned by no other plan, and a triaged register for the 23-item deferred-work inventory.
|
||
|
||
**Architecture:** All changes are convention-hardening around existing seams: eager options validation copies the shipped `OptionsValidatorBase<T>` pattern (`ZB.MOM.WW.Configuration` package) already used by HealthMonitoring/ClusterInfrastructure/AuditLog/KpiHistory; the vestigial notification removal relies on the existing `PurgeCentralOnlyNotificationConfigAsync` guarantee (which stays); new tests slot into existing test projects (PerformanceTests, Commons.Tests). No new runtime components, no schema changes, no wire-contract changes.
|
||
|
||
**Tech Stack:** C# / .NET 10, xUnit, Akka.NET TestKit/Streams, `Microsoft.Extensions.Options` (`IValidateOptions<>` + `ValidateOnStart`), Newtonsoft.Json (contract-lock tests only), SQLite (site storage).
|
||
|
||
**Build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. Tests: `dotnet test ZB.MOM.WW.ScadaBridge.slnx` — after Task 1 this picks up CLI.Tests too.
|
||
|
||
## Parallelization (incomplete tasks) — 2026-07-09
|
||
|
||
T1 complete. **Ready now** (`blockedBy` ⊆ {1}): **{2, 3, 4, 5, 7, 8, 9, 11}**.
|
||
|
||
This is the **highest-parallelism slice in the initiative** — T2–T5 each create per-component options-validator files, and T7/T8/T9/T11 are isolated test/props/doc files, so they are **mutually parallel** and can run as many concurrent implementers at once, with a single caveat:
|
||
- **`Host/Program.cs` mutex:** T4 edits `Program.cs` (shared with PLAN-06 T7) — sequence those two. (T3 edits `Host/SiteServiceRegistration.cs`, a *different* Host file — no conflict.)
|
||
|
||
**Blocked:** T6 (`blockedBy` T3 — removes the vestigial site notification surface, shares the SiteRuntime project), T10 (`blockedBy` 1, 11 — docs-drift cleanup, touches `CLAUDE.md` + `ManagementService.csproj`, coordinate with T4 which also edits that csproj). See [00-MASTER-TRACKER.md § Parallelization Map](00-MASTER-TRACKER.md#parallelization-map--incomplete-tasks-2026-07-09).
|
||
|
||
## Findings Coverage
|
||
|
||
| Report finding (08-conventions-tests-underdeveloped.md) | Severity | Coverage |
|
||
|---|---|---|
|
||
| §1.1 Commons folders beyond the documented four | Low | Task 10 — **narrowed on re-verify**: `Serialization/` and `Validators/` are already in `Component-Commons.md`'s layout tree (lines 293–294); only `Observability/` is missing |
|
||
| §1.2 POCO persistence-ignorance | Pass | No action |
|
||
| §1.3 Site-side repository-implementation exception undocumented | Low | Task 10 |
|
||
| §1.3 / §3 item 23 Vestigial `SiteNotificationRepository` + `StoreNotificationListAsync` write path | Low/Medium | Task 6 |
|
||
| §1.4 `Communication` → `HealthMonitoring` layering inversion | Low | **Deferred** (Task 11 register entry): moving `ICentralHealthAggregator` + `SiteHealthState` to Commons ripples across HealthMonitoring, Communication, ManagementService, CentralUI, AuditLog + their tests — disproportionate to a cosmetic note; documented as accepted with revisit trigger |
|
||
| §1.4 `ManagementService.csproj` `..\` path-separator inconsistency | Low | Task 10 |
|
||
| §1.5 Options validation on only ~6 of ~20 option classes | Medium | Tasks 2, 3, 4, 5 |
|
||
| §1.6 UTC / §1.7 correlation IDs / §1.9 naming | Pass | No action |
|
||
| §1.8 No contract-lock tests for ClusterClient record contracts (also Rec 10) | Low | Task 9 |
|
||
| §2.1 Thin test spots (SiteCallAudit.Tests, DeploymentManager.Tests) | Info | **Deferred** (Task 11 register entry): no defect identified; backfill tracked with revisit trigger |
|
||
| §2.3 PerformanceTests is a correctness-at-small-scale suite | Medium | Tasks 7 (streaming throughput) + 8 (failover-timing harness placeholder, delegating the rig to Plan 01) |
|
||
| §2.4 CLI.Tests excluded from slnx | High | Task 1 |
|
||
| §3 item 1 / §4 Transport `AreaName: null` vs doc claim | High | **Owned by plan 05** |
|
||
| §3 item 3 AuditLog reconciliation dials NodeA only | Medium | **Owned by plan 04** |
|
||
| §3 item 5 `SmsConfiguration.MaxRetryCount` stored but unused | Medium | **Owned by plan 06** |
|
||
| §3 item 6 Role-check case-sensitivity asymmetry | Medium | **Owned by plan 07** |
|
||
| §3 items 7–22 (hash-chain, Parquet, live alarm stream, cert-trust central persistence, CSV parity, WaitForAttribute, BrowseNext, StubOpcUaClient, unified outbox page, folder drag-drop, bundle signing, EXPIRED purge, backlog-reporter options, KPI downsampling) | Low | Task 11 — each gets an explicit register row with defer rationale + revisit trigger (none needs code in this plan) |
|
||
| §4 `docs/components/` lags 3 components vs README claim | Low | Task 10 (scope the claim; backfill tracked in Task 11 register) |
|
||
| §4 DelmiaNotifier breaks `Component-<Name>.md` convention | Low | Task 10 (document the exception) |
|
||
| §4 Component-KpiHistory / StoreAndForward / ClusterInfrastructure doc sync | Pass | No action |
|
||
|
||
---
|
||
|
||
### Task 1: Add CLI.Tests to the slnx and retire the gotcha documentation
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** 2, 3, 4, 5, 6, 7, 8, 9, 11
|
||
**Files:**
|
||
- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (insert one line after line 51, the ManagementService.Tests entry, mirroring src ordering)
|
||
- Modify: `CLAUDE.md` (line 69, the Editing Rules test-command bullet)
|
||
- Modify: `/Users/dohertj2/.claude/projects/-Users-dohertj2-Desktop-ScadaBridge/memory/MEMORY.md` (index entry "CLI.Tests is not in the slnx") and `/Users/dohertj2/.claude/projects/-Users-dohertj2-Desktop-ScadaBridge/memory/cli-tests-not-in-slnx.md`
|
||
|
||
1. Verify current state (expect CLI.Tests absent):
|
||
```bash
|
||
grep -c "CLI.Tests" ZB.MOM.WW.ScadaBridge.slnx # expect: 0
|
||
```
|
||
2. Edit `ZB.MOM.WW.ScadaBridge.slnx`: after the line
|
||
` <Project Path="tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests.csproj" />`
|
||
insert:
|
||
```xml
|
||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ZB.MOM.WW.ScadaBridge.CLI.Tests.csproj" />
|
||
```
|
||
3. Verify the solution builds and the CLI tests now run at the solution level:
|
||
```bash
|
||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||
dotnet test ZB.MOM.WW.ScadaBridge.slnx --no-build --filter "FullyQualifiedName~ScadaBridge.CLI.Tests"
|
||
```
|
||
Expected: build succeeds; the test run lists `ZB.MOM.WW.ScadaBridge.CLI.Tests` and reports ~279 passing tests (0 failed). If any CLI test fails to compile, fix forward — that is exactly the rot this task exists to expose.
|
||
4. Update `CLAUDE.md:69`. Replace the bullet:
|
||
> `- Run tests with \`dotnet test ZB.MOM.WW.ScadaBridge.slnx\`, **but \`ZB.MOM.WW.ScadaBridge.CLI.Tests\` is not in the slnx** and is silently skipped — when touching the CLI, test that project directly (\`dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests\`), or a green solution run can hide CLI tests that never compiled.`
|
||
|
||
with:
|
||
> `- Run tests with \`dotnet test ZB.MOM.WW.ScadaBridge.slnx\`. All 30 test projects, including \`ZB.MOM.WW.ScadaBridge.CLI.Tests\`, are members of the slnx (CLI.Tests was added 2026-07; the old "silently skipped" gotcha no longer applies).`
|
||
5. Update the auto-memory: in `MEMORY.md`, change the index line for `cli-tests-not-in-slnx.md` to note the gotcha is fixed (e.g. "FIXED 2026-07: CLI.Tests is now in the slnx; entry kept as history"), and add a first-line status note to `cli-tests-not-in-slnx.md` itself.
|
||
6. Commit:
|
||
```bash
|
||
git add ZB.MOM.WW.ScadaBridge.slnx CLAUDE.md
|
||
git commit -m "build(slnx): add CLI.Tests to the solution — retire the silently-skipped-279-tests gotcha (arch-review 08 §2.4)"
|
||
```
|
||
(The memory files live outside the repo and are not committed.)
|
||
|
||
---
|
||
|
||
### Task 2: Options validation — Communication + DataConnectionLayer
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 3, 4, 5, 6, 7, 8, 9, 10, 11
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs`
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs` (lines 13–14), `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs` (lines 15–22)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj`, `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj` (add `<PackageReference Include="ZB.MOM.WW.Configuration" />` — no Version attribute; central package management already has it, see `HealthMonitoring.csproj:15`)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionOptionsValidatorTests.cs`
|
||
|
||
The pattern to copy is `src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/HealthMonitoringOptionsValidator.cs` (an `OptionsValidatorBase<T>` from the `ZB.MOM.WW.Configuration` package with `builder.RequireThat(...)` per field) registered via `TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<T>, TValidator>())` + `.ValidateOnStart()` on the `AddOptions` chain (`HealthMonitoring/ServiceCollectionExtensions.cs:157-162`). Tests copy `tests/ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests/HealthMonitoringOptionsValidatorTests.cs`.
|
||
|
||
1. Write failing tests first — `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs`:
|
||
```csharp
|
||
using Microsoft.Extensions.Options;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||
|
||
public class CommunicationOptionsValidatorTests
|
||
{
|
||
private static ValidateOptionsResult Validate(CommunicationOptions options) =>
|
||
new CommunicationOptionsValidator().Validate(Options.DefaultName, options);
|
||
|
||
[Fact]
|
||
public void DefaultOptions_AreValid()
|
||
{
|
||
var result = Validate(new CommunicationOptions());
|
||
Assert.True(result.Succeeded, result.FailureMessage);
|
||
}
|
||
|
||
[Fact]
|
||
public void ZeroDeploymentTimeout_IsRejected()
|
||
{
|
||
var result = Validate(new CommunicationOptions { DeploymentTimeout = TimeSpan.Zero });
|
||
Assert.True(result.Failed);
|
||
Assert.Contains("DeploymentTimeout", result.FailureMessage);
|
||
}
|
||
|
||
[Fact]
|
||
public void NonPositiveGrpcMaxConcurrentStreams_IsRejected()
|
||
{
|
||
var result = Validate(new CommunicationOptions { GrpcMaxConcurrentStreams = 0 });
|
||
Assert.True(result.Failed);
|
||
Assert.Contains("GrpcMaxConcurrentStreams", result.FailureMessage);
|
||
}
|
||
}
|
||
```
|
||
Mirror the same three-test shape for `DataConnectionOptionsValidator` (`ReconnectInterval`, `SeedReadMaxAttempts`).
|
||
2. Run, expect FAIL (validator types don't exist → compile error, which counts):
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "FullyQualifiedName~CommunicationOptionsValidator"
|
||
```
|
||
3. Implement `CommunicationOptionsValidator` (positive-duration checks for every `TimeSpan` on `CommunicationOptions` — `DeploymentTimeout`, `LifecycleTimeout`, `ArtifactDeploymentTimeout`, `QueryTimeout`, `IntegrationTimeout`, `DebugViewTimeout`, `HealthReportTimeout`, `NotificationForwardTimeout`, `GrpcKeepAlivePingDelay`, `GrpcKeepAlivePingTimeout`, `GrpcMaxStreamLifetime`, `TransportHeartbeatInterval`, `TransportFailureThreshold` — plus `GrpcMaxConcurrentStreams > 0`). Error messages must name the config key (`Communication:<Field>`), same style as `HealthMonitoringOptionsValidator`:
|
||
```csharp
|
||
using ZB.MOM.WW.Configuration;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.Communication;
|
||
|
||
/// <summary>
|
||
/// Validates <see cref="CommunicationOptions"/> at startup (ValidateOnStart) so a
|
||
/// malformed "Communication" appsettings section fails fast at boot with a
|
||
/// key-naming message instead of surfacing at first Ask/gRPC use.
|
||
/// </summary>
|
||
public sealed class CommunicationOptionsValidator : OptionsValidatorBase<CommunicationOptions>
|
||
{
|
||
protected override void Validate(ValidationBuilder builder, CommunicationOptions options)
|
||
{
|
||
builder.RequireThat(options.DeploymentTimeout > TimeSpan.Zero,
|
||
$"Communication:DeploymentTimeout must be a positive duration (was {options.DeploymentTimeout}).");
|
||
// ... one RequireThat per field listed above ...
|
||
builder.RequireThat(options.GrpcMaxConcurrentStreams > 0,
|
||
$"Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams}).");
|
||
}
|
||
}
|
||
```
|
||
`DataConnectionOptionsValidator` likewise: `ReconnectInterval`, `TagResolutionRetryInterval`, `WriteTimeout`, `SeedReadTimeout`, `StableConnectionThreshold`, `SeedReadRetryDelay` positive durations; `SeedReadMaxAttempts > 0`. Read `OpcUaGlobalOptions`/`MxGatewayGlobalOptions` (`src/.../DataConnectionLayer/`) and add `RequireThat` checks for any numeric/duration fields they carry (add validators for them only if they have such fields).
|
||
4. Wire registration. `Communication/ServiceCollectionExtensions.cs:13-14` becomes:
|
||
```csharp
|
||
services.AddOptions<CommunicationOptions>()
|
||
.BindConfiguration("Communication")
|
||
.ValidateOnStart();
|
||
services.TryAddEnumerable(
|
||
ServiceDescriptor.Singleton<IValidateOptions<CommunicationOptions>, CommunicationOptionsValidator>());
|
||
```
|
||
Same shape for the three `AddOptions` calls in `DataConnectionLayer/ServiceCollectionExtensions.cs:15-22`. Add the `ZB.MOM.WW.Configuration` PackageReference to both csprojs.
|
||
5. Run, expect PASS:
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||
```
|
||
6. Commit:
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.Communication src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer tests/ZB.MOM.WW.ScadaBridge.Communication.Tests tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests
|
||
git commit -m "feat(options): eager startup validation for Communication + DataConnectionLayer options (arch-review 08 §1.5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Options validation — site pipeline (SiteRuntime, StoreAndForward, SiteEventLogging)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 2, 4, 5, 7, 8, 9, 10, 11 (NOT 6 — both touch the SiteRuntime project; run sequentially to keep csproj/test churn separate)
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.StoreAndForward/StoreAndForwardOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogOptionsValidator.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs` (lines 124–127 — the `services.Configure<T>(config.GetSection(...))` calls for these three options)
|
||
- Modify: the three component csprojs (add `<PackageReference Include="ZB.MOM.WW.Configuration" />`)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests/StoreAndForwardOptionsValidatorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogOptionsValidatorTests.cs`
|
||
|
||
These three options classes are bound in the Host, not in the component SCEs (`Host/SiteServiceRegistration.cs:124-127`), so binding conversion happens there; validator classes live with their owners per the "options classes owned by component projects" convention.
|
||
|
||
1. Write failing tests (same shape as Task 2 step 1; one `DefaultOptions_AreValid` + 2–3 rejection cases per validator). Key rejection cases:
|
||
- `SiteRuntimeOptions`: `StartupBatchSize = 0`, `ScriptExecutionThreadCount = 0`, `StreamBufferSize = 0` (all feed thread-pool/stream sizing; also validate `StartupBatchDelayMs >= 0`, `MaxScriptCallDepth > 0`, `ScriptExecutionTimeoutSeconds > 0`, `MirroredAlarmCapPerSource > 0`, `NativeAlarmRetryIntervalMs > 0`, `ConfigFetchTimeoutSeconds > 0`, `ConfigFetchRetryCount >= 0`).
|
||
- `StoreAndForwardOptions`: `DefaultRetryInterval = TimeSpan.Zero`, `RetryTimerInterval = TimeSpan.Zero`, `SqliteDbPath = ""` (also `DefaultMaxRetries >= 0`).
|
||
- `SiteEventLogOptions`: `RetentionDays = 0`, `PurgeInterval = TimeSpan.Zero`, `MaxQueryPageSize < QueryPageSize` cross-check (also `MaxStorageMb > 0`, `WriteQueueCapacity > 0`, `DatabasePath` non-empty).
|
||
2. Run, expect FAIL (compile error on missing validator types):
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
```
|
||
3. Implement the three validators (`OptionsValidatorBase<T>`, key-naming messages using the `ScadaBridge:SiteRuntime` / `ScadaBridge:StoreAndForward` / `ScadaBridge:SiteEventLog` prefixes). Convert the Host bindings — `Host/SiteServiceRegistration.cs:124-127` pattern per option:
|
||
```csharp
|
||
services.AddOptions<SiteRuntimeOptions>()
|
||
.Bind(config.GetSection("ScadaBridge:SiteRuntime"))
|
||
.ValidateOnStart();
|
||
services.TryAddEnumerable(
|
||
ServiceDescriptor.Singleton<IValidateOptions<SiteRuntimeOptions>, SiteRuntimeOptionsValidator>());
|
||
```
|
||
(Keep any other `Configure<>` calls in that block untouched.)
|
||
4. Run, expect PASS; then guard against startup regressions (Host composition-root tests boot both roles):
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~CompositionRoot"
|
||
```
|
||
5. Commit:
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime src/ZB.MOM.WW.ScadaBridge.StoreAndForward src/ZB.MOM.WW.ScadaBridge.SiteEventLogging src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests tests/ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests
|
||
git commit -m "feat(options): eager startup validation for site-pipeline options (SiteRuntime, S&F, SiteEventLog) (arch-review 08 §1.5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Options validation — edge + management (InboundAPI, ExternalSystemGateway, NotificationService, ManagementService)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 2, 3, 5, 6, 7, 8, 9, 10, 11
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemGatewayOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.NotificationService/NotificationOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptionsValidator.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (line 273 — `Configure<InboundApiOptions>` → `AddOptions().Bind().ValidateOnStart()` + validator registration), `src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ServiceCollectionExtensions.cs` (lines 23–24), `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs` (lines 20–21), `src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs` (lines 15–16)
|
||
- Modify: the four component csprojs (add `ZB.MOM.WW.Configuration` PackageReference)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundApiOptionsValidatorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemGatewayOptionsValidatorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests/NotificationOptionsValidatorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementServiceOptionsValidatorTests.cs`
|
||
|
||
1. Write failing tests (Task 2 shape). Rejection cases:
|
||
- `InboundApiOptions`: `DefaultMethodTimeout = TimeSpan.Zero`, `MaxRequestBodyBytes = 0`. Do **not** require `ApiKeyPepper` non-empty (empty is a legitimate dev default — check `Host/Program.cs` usage before deciding; if a non-empty pepper is required in production the `StartupValidator` owns that, not this validator).
|
||
- `ExternalSystemGatewayOptions`: `DefaultHttpTimeout = TimeSpan.Zero`, `MaxConcurrentConnectionsPerSystem = 0`.
|
||
- `NotificationOptions`: `ConnectionTimeoutSeconds = 0`, `MaxConcurrentConnections = 0`.
|
||
- `ManagementServiceOptions`: `CommandTimeout = TimeSpan.Zero`.
|
||
2. Run one project, expect FAIL:
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
```
|
||
3. Implement the four validators (key prefixes `ScadaBridge:InboundApi`, `ScadaBridge:ExternalSystemGateway`, `ScadaBridge:Notification`, `ScadaBridge:ManagementService`) and convert each binding site to `AddOptions<T>().BindConfiguration(...)/.Bind(...).ValidateOnStart()` + `TryAddEnumerable` validator registration (exactly the Task 2 step 4 shape).
|
||
4. Run, expect PASS:
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~CompositionRoot"
|
||
```
|
||
5. Commit:
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.InboundAPI src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway src/ZB.MOM.WW.ScadaBridge.NotificationService src/ZB.MOM.WW.ScadaBridge.ManagementService src/ZB.MOM.WW.ScadaBridge.Host/Program.cs tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests tests/ZB.MOM.WW.ScadaBridge.NotificationService.Tests tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests
|
||
git commit -m "feat(options): eager startup validation for edge/management options (InboundAPI, ESG, Notification, ManagementService) (arch-review 08 §1.5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Options validation — central components (Transport, SiteCallAudit, DeploymentManager)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 2, 3, 4, 6, 7, 8, 9, 10, 11
|
||
**Files:**
|
||
- Create: `src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/SiteCallAuditOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentManagerOptionsValidator.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/ServiceCollectionExtensions.cs` (line 25), `src/ZB.MOM.WW.ScadaBridge.SiteCallAudit/ServiceCollectionExtensions.cs` (lines 40–41), `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs` (line 31)
|
||
- Modify: the three component csprojs (add `ZB.MOM.WW.Configuration` PackageReference)
|
||
- Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditOptionsValidatorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentManagerOptionsValidatorTests.cs`
|
||
|
||
1. Write failing tests (Task 2 shape). Rejection cases:
|
||
- `TransportOptions` (`ScadaBridge:Transport`): `BundleSessionTtlMinutes = 0`, `MaxBundleSizeMb = 0`, `Pbkdf2Iterations = 0` (security-sensitive: also require `Pbkdf2Iterations >= 100_000` so a config typo cannot silently weaken bundle key derivation), `MaxUnlockAttemptsPerSession = 0`, plus positivity for `MaxBundleEntryDecompressedMb`, `MaxBundleEntryCount`, `MaxBundleEntryCompressionRatio`, `MaxUnlockAttemptsPerIpPerHour`, `SchemaVersionMajor`, and `SourceEnvironment` non-empty.
|
||
- `SiteCallAuditOptions` (`ScadaBridge:SiteCallAudit`): `StuckAgeThreshold = TimeSpan.Zero`, `KpiInterval = TimeSpan.Zero`, `ReconciliationBatchSize = 0`, `RetentionDays = 0`, plus positivity for `RelayTimeout`, `ReconciliationInterval`, `PurgeInterval`. Note: validate the base properties, not the `Resolved*` computed properties.
|
||
- `DeploymentManagerOptions` (`ScadaBridge:DeploymentManager` — confirm the section name at the binding site; `ServiceCollectionExtensions.cs:31` uses bare `AddOptions<DeploymentManagerOptions>()`, i.e. code-configured defaults, so ValidateOnStart still applies): `LifecycleCommandTimeout = TimeSpan.Zero`, `ArtifactDeploymentTimeoutPerSite = TimeSpan.Zero`, `OperationLockTimeout = TimeSpan.Zero`.
|
||
2. Run one project, expect FAIL:
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
```
|
||
3. Implement the three validators + registration conversion (Task 2 step 4 shape). Transport becomes:
|
||
```csharp
|
||
services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection).ValidateOnStart();
|
||
services.TryAddEnumerable(
|
||
ServiceDescriptor.Singleton<IValidateOptions<TransportOptions>, TransportOptionsValidator>());
|
||
```
|
||
4. Run, expect PASS:
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter "FullyQualifiedName~OptionsValidator"
|
||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||
```
|
||
5. Commit:
|
||
```bash
|
||
git add src/ZB.MOM.WW.ScadaBridge.Transport src/ZB.MOM.WW.ScadaBridge.SiteCallAudit src/ZB.MOM.WW.ScadaBridge.DeploymentManager tests/ZB.MOM.WW.ScadaBridge.Transport.Tests tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests
|
||
git commit -m "feat(options): eager startup validation for central-component options (Transport, SiteCallAudit, DeploymentManager) (arch-review 08 §1.5)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Remove the vestigial site-side notification-list surface
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 2, 4, 5, 7, 8, 9, 11 (NOT 3 — same project; NOT 10 — both touch `docs/components/SiteRuntime.md`? No: Task 10 does not touch that file. Parallelizable with 10 too, but keep 3 sequential.)
|
||
**Files:**
|
||
- Delete: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/SiteNotificationRepository.cs`
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs` (line 65 — remove `services.AddScoped<INotificationRepository, SiteNotificationRepository>();`)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs` — remove `StoreNotificationListAsync` (lines 715–739) and `StoreSmtpConfigurationAsync` (lines ~741–785). **Keep** `PurgeCentralOnlyNotificationConfigAsync` (lines 691–713) and the table schemas — the purge is the security cleanup for DBs written by older builds.
|
||
- Modify tests: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs` (lines 444–451), `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs` (~lines 185–200), `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs` (all `StoreNotificationListAsync`/`StoreSmtpConfigurationAsync`/`SiteNotificationRepository` usages: ~lines 125–195)
|
||
- Modify: `docs/components/SiteRuntime.md` (line 15 — drop `SiteNotificationRepository` from the `Repositories/` listing and note why)
|
||
|
||
Context (verified): the central-only notification design means `PurgeCentralOnlyNotificationConfigAsync` empties `notification_lists` + `smtp_configurations` on every artifact apply (`DeploymentManagerActor.cs:1556`, `SiteReplicationActor.cs:267`), so the registered `SiteNotificationRepository` can only ever read empty tables, and the two `Store*` methods have **zero production callers** (grep: only tests). `SiteExternalSystemRepository` is NOT vestigial — external system definitions do ship to sites — leave it alone.
|
||
|
||
1. Write the failing test first — replace `Site_NotificationRepository_IsSiteImplementation` in `CompositionRootTests.cs:444-451` with a design-invariant lock:
|
||
```csharp
|
||
[Fact]
|
||
public void Site_NotificationRepository_IsNotRegistered()
|
||
{
|
||
// Notification delivery is central-only (CLAUDE.md "Notification delivery is
|
||
// central-only"); the site-local notification_lists/smtp_configurations tables
|
||
// are purged on every artifact apply, so a site-side INotificationRepository
|
||
// could only ever serve empty results. It must not be registered at all.
|
||
using var scope = _host.Services.CreateScope();
|
||
var repo = scope.ServiceProvider.GetService<INotificationRepository>();
|
||
Assert.Null(repo);
|
||
}
|
||
```
|
||
2. Run, expect FAIL (repo is still registered):
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~Site_NotificationRepository"
|
||
```
|
||
3. Implement: delete `SiteNotificationRepository.cs`; remove the DI registration at `SiteRuntime/ServiceCollectionExtensions.cs:65`; delete `StoreNotificationListAsync` + `StoreSmtpConfigurationAsync` from `SiteStorageService.cs`; extend the doc comment on `PurgeCentralOnlyNotificationConfigAsync` with one sentence noting the write paths and site repository were removed (with date + review pointer) so a future maintainer doesn't reintroduce them. Check whether `SiteRuntime/Repositories/SyntheticId.cs` is still used by `SiteExternalSystemRepository` — if yes keep it, if the notification repo was its last consumer delete it too.
|
||
4. Delete/rewrite the orphaned tests: the notification-list blocks in `SiteRepositoryTests.cs` and `ArtifactStorageTests.cs` (they exercise deleted API). Where a test asserted persistence-across-restart semantics generically, keep the external-system variant only.
|
||
5. Run, expect PASS:
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~CompositionRoot"
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests
|
||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||
```
|
||
6. Update `docs/components/SiteRuntime.md:15` (`Repositories/` bullet → `SiteExternalSystemRepository` only, with a one-line note that the notification variant was removed because notification config never lives on sites).
|
||
7. Commit:
|
||
```bash
|
||
git add -A src/ZB.MOM.WW.ScadaBridge.SiteRuntime tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests tests/ZB.MOM.WW.ScadaBridge.Host.Tests docs/components/SiteRuntime.md
|
||
git commit -m "refactor(site-runtime): excise vestigial site-side notification-list surface — repo, DI registration, dead write paths (arch-review 08 §1.3/#23)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: PerformanceTests — site-stream throughput test
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 2, 3, 4, 5, 6, 9, 10, 11 (and 8 — different new files)
|
||
**Files:**
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Streaming/SiteStreamThroughputTests.cs`
|
||
- (No csproj change needed: `PerformanceTests.csproj` already references SiteRuntime and Commons.)
|
||
|
||
This adds the first of the two highest-value real measurements the review called for (§2.3): sustained throughput of the site-wide Akka stream (`SiteStreamManager`, `Source.ActorRef` + `BroadcastHub`, per-subscriber buffering) that carries every attribute value and alarm state change on a site. Follows the existing `HotPathLatencyTests` style: `Stopwatch`-based, `[Trait("Category", "Performance")]`, conservative thresholds so CI stays green on slow machines.
|
||
|
||
1. Write the test (this is the implementation — a perf test is its own assertion):
|
||
```csharp
|
||
using System.Diagnostics;
|
||
using Akka.Actor;
|
||
using Akka.TestKit.Xunit2;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.Streaming;
|
||
|
||
/// <summary>
|
||
/// Throughput measurement for the site-wide attribute/alarm stream
|
||
/// (SiteStreamManager: Source.ActorRef → BroadcastHub with per-subscriber
|
||
/// buffering, DropHead overflow). Design envelope: this stream carries every
|
||
/// attribute value change on a site; it must comfortably sustain tens of
|
||
/// thousands of events/sec. Thresholds are deliberately conservative
|
||
/// (CI-machine safe) — this test exists to catch order-of-magnitude
|
||
/// regressions, not to benchmark.
|
||
/// </summary>
|
||
public class SiteStreamThroughputTests : TestKit
|
||
{
|
||
private sealed class CountingActor : ReceiveActor
|
||
{
|
||
public static long Received;
|
||
public CountingActor()
|
||
{
|
||
Receive<AttributeValueChanged>(_ => Interlocked.Increment(ref Received));
|
||
ReceiveAny(_ => { });
|
||
}
|
||
}
|
||
|
||
[Trait("Category", "Performance")]
|
||
[Fact]
|
||
public async Task SiteStream_SustainsAtLeast10kEventsPerSecond_ToOneSubscriber()
|
||
{
|
||
const int eventCount = 100_000;
|
||
CountingActor.Received = 0;
|
||
|
||
var manager = new SiteStreamManager(
|
||
new SiteRuntimeOptions { StreamBufferSize = eventCount },
|
||
NullLogger<SiteStreamManager>.Instance);
|
||
manager.Initialize(Sys);
|
||
|
||
var counter = Sys.ActorOf(Props.Create<CountingActor>());
|
||
manager.Subscribe("PerfInstance", counter);
|
||
|
||
var sw = Stopwatch.StartNew();
|
||
for (var i = 0; i < eventCount; i++)
|
||
{
|
||
manager.PublishAttributeValueChanged(new AttributeValueChanged(
|
||
"PerfInstance", "Attr", "Attr", i, "Good", DateTimeOffset.UtcNow));
|
||
}
|
||
|
||
// Drain: wait until the counter goes quiet (no growth across a 200ms window).
|
||
long last = -1;
|
||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
var now = Interlocked.Read(ref CountingActor.Received);
|
||
if (now == last && now > 0) break;
|
||
last = now;
|
||
await Task.Delay(200);
|
||
}
|
||
sw.Stop();
|
||
|
||
var received = Interlocked.Read(ref CountingActor.Received);
|
||
var eventsPerSecond = received / sw.Elapsed.TotalSeconds;
|
||
|
||
// DropHead means under extreme pressure some events may drop; with the
|
||
// buffer sized to the burst, deliver ratio should be ~1.0.
|
||
Assert.True(received >= eventCount * 0.9,
|
||
$"Expected >=90% delivery, got {received}/{eventCount}");
|
||
Assert.True(eventsPerSecond >= 10_000,
|
||
$"Expected >=10k events/s sustained, got {eventsPerSecond:F0}/s over {sw.Elapsed.TotalSeconds:F1}s");
|
||
}
|
||
}
|
||
```
|
||
Adjust to actual constructor/subscribe signatures on read (`SiteStreamManager.Subscribe(string instanceName, IActorRef subscriber)` — verified; subscription filters by `InstanceUniqueName`). If subscriber delivery turns out to route a wrapper message rather than the raw record, match on the wrapper.
|
||
2. Run, expect PASS (and eyeball the reported rate in the failure-message format by temporarily inverting the assertion if you want the number):
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.PerformanceTests --filter "FullyQualifiedName~SiteStreamThroughput"
|
||
```
|
||
3. Commit:
|
||
```bash
|
||
git add tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Streaming
|
||
git commit -m "test(perf): site-wide stream throughput measurement — first real perf-envelope test (arch-review 08 §2.3)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: PerformanceTests — failover-timing benchmark harness placeholder
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** 1, 2, 3, 4, 5, 6, 7, 9, 10, 11
|
||
**Files:**
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs`
|
||
|
||
The design claims ~25s total failover (CLAUDE.md "Cluster & Failover"). Measuring it needs a real two-node cluster with a hard kill — **that rig is owned by Plan 01** (the two-node failover test rig from overall recommendation P2-10). This task ships the measurement protocol as an explicitly-skipped harness so the perf suite documents the envelope and Plan 01's rig has a ready seat to plug into; it must NOT duplicate the rig.
|
||
|
||
1. Create the placeholder:
|
||
```csharp
|
||
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.Failover;
|
||
|
||
/// <summary>
|
||
/// Failover-timing benchmark harness (placeholder).
|
||
///
|
||
/// Design envelope under test (CLAUDE.md "Cluster & Failover"): failure
|
||
/// detection 2s heartbeat / 10s threshold, SBR stable-after 15s, total
|
||
/// failover ~25s for a hard node loss.
|
||
///
|
||
/// Measurement protocol (to be wired to the two-node cluster rig delivered
|
||
/// by PLAN-01 — see archreview/plans/PLAN-01-*.md; do not duplicate that rig
|
||
/// here):
|
||
/// 1. Form a real 2-node cluster (docker/ topology or Akka.Remote in-proc
|
||
/// multi-ActorSystem rig from PLAN-01).
|
||
/// 2. Confirm a cluster singleton (e.g. site DeploymentManager) is hosted
|
||
/// on node A; record T0.
|
||
/// 3. Hard-kill node A (process kill / ActorSystem.Abort — not
|
||
/// CoordinatedShutdown; graceful stop exercises a different path).
|
||
/// 4. Poll node B for singleton re-host; record T1 at first successful
|
||
/// response from the migrated singleton.
|
||
/// 5. Assert T1 - T0 <= 40s (25s design + margin) and report the number.
|
||
/// </summary>
|
||
public class FailoverTimingTests
|
||
{
|
||
[Trait("Category", "Performance")]
|
||
[Fact(Skip = "Requires the real two-node failover rig delivered by PLAN-01 " +
|
||
"(overall review P2-10). This class documents the measurement " +
|
||
"protocol; wire it to the rig when PLAN-01 lands.")]
|
||
public void HardKillFailover_SingletonRehostedWithinDesignEnvelope()
|
||
{
|
||
// Intentionally empty until the PLAN-01 rig exists.
|
||
}
|
||
}
|
||
```
|
||
2. Verify it compiles and shows as skipped:
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.PerformanceTests --filter "FullyQualifiedName~FailoverTiming"
|
||
```
|
||
Expected: 1 skipped, 0 failed.
|
||
3. Commit:
|
||
```bash
|
||
git add tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover
|
||
git commit -m "test(perf): failover-timing harness placeholder documenting the 25s envelope protocol, pending PLAN-01 two-node rig (arch-review 08 §2.3)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Contract-lock tests for top ClusterClient message records
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 2, 3, 4, 5, 6, 7, 8, 10, 11
|
||
**Files:**
|
||
- Create: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ClusterClientContractLockTests.cs`
|
||
- Modify: `Directory.Packages.props` (add `<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />` — first check `dotnet list tests/ZB.MOM.WW.ScadaBridge.Commons.Tests package --include-transitive | grep -i newtonsoft` and pin to the version Akka already pulls transitively, to avoid a version bump ride-along)
|
||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ZB.MOM.WW.ScadaBridge.Commons.Tests.csproj` (add `<PackageReference Include="Newtonsoft.Json" />`)
|
||
|
||
Rationale (§1.8, Rec 10): ClusterClient traffic rides Akka.NET's default Newtonsoft-JSON serialization; "additive-only evolution" is currently discipline, not enforcement — the gRPC side has proto contract-lock tests (`Communication.Tests/Protos/*ProtoTests.cs`, `Grpc/ProtoContractTests.cs`) but the record contracts have none. These tests lock the version-skew guarantees: (a) an old-shape payload (missing newer trailing optional fields) still deserializes with defaults, (b) unknown fields from a *newer* peer are ignored, (c) round-trip preserves values.
|
||
|
||
Records to lock (highest-traffic cross-cluster surface): `ManagementEnvelope` (`Commons/Messages/Management/ManagementEnvelope.cs`), `DeployInstanceCommand` (`Messages/Deployment/DeployInstanceCommand.cs` — has documented optional trailing fields `CentralFetchBaseUrl`/`FetchToken`), `GetAttributeRequest` + `SetStaticAttributeCommand` (`Messages/Instance/`), `AttributeValueChanged` (`Messages/Streaming/`), one notification-forward record from `Messages/Notification/NotificationMessages.cs`, and `CreateDataConnectionCommand` (`Messages/Management/DataConnectionCommands.cs` — the report's own example of defaulted trailing parameters).
|
||
|
||
1. Write the failing test file (fails to compile until the package reference lands — that is the FAIL step):
|
||
```csharp
|
||
using Newtonsoft.Json;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||
|
||
/// <summary>
|
||
/// Contract-lock tests for the highest-traffic ClusterClient record contracts.
|
||
/// ClusterClient traffic uses Akka.NET's default Newtonsoft-JSON serializer and
|
||
/// the design supports system-wide artifact version skew across sites, so these
|
||
/// records must obey additive-only evolution: old-shape payloads deserialize
|
||
/// (missing new fields → defaults) and unknown fields are ignored. If a change
|
||
/// breaks one of these tests, it breaks rolling cross-cluster compatibility —
|
||
/// do not "fix the test"; make the change additive.
|
||
/// </summary>
|
||
public class ClusterClientContractLockTests
|
||
{
|
||
private static readonly JsonSerializerSettings AkkaLikeSettings = new()
|
||
{
|
||
MissingMemberHandling = MissingMemberHandling.Ignore,
|
||
};
|
||
|
||
[Fact]
|
||
public void CreateDataConnectionCommand_OldShape_WithoutNewerTrailingFields_Deserializes()
|
||
{
|
||
// Shape as emitted before BackupConfiguration/FailoverRetryCount existed.
|
||
// (Adjust property list to the record's actual required leading fields.)
|
||
const string oldShape =
|
||
"""{"Name":"conn1","SiteId":3,"Protocol":"OpcUa","Configuration":"{}"}""";
|
||
|
||
var cmd = JsonConvert.DeserializeObject<CreateDataConnectionCommand>(oldShape, AkkaLikeSettings);
|
||
|
||
Assert.NotNull(cmd);
|
||
Assert.Null(cmd!.BackupConfiguration); // default applied
|
||
Assert.Equal(3, cmd.FailoverRetryCount); // default applied
|
||
}
|
||
|
||
[Fact]
|
||
public void AttributeValueChanged_UnknownFieldFromNewerPeer_IsIgnored()
|
||
{
|
||
const string newerShape =
|
||
"""{"InstanceUniqueName":"i","AttributePath":"a","AttributeName":"a","Value":1,"Quality":"Good","Timestamp":"2026-07-08T00:00:00+00:00","SomeFutureField":"x"}""";
|
||
|
||
var evt = JsonConvert.DeserializeObject<AttributeValueChanged>(newerShape, AkkaLikeSettings);
|
||
|
||
Assert.NotNull(evt);
|
||
Assert.Equal("Good", evt!.Quality);
|
||
}
|
||
|
||
[Fact]
|
||
public void ManagementEnvelope_RoundTrip_PreservesCorrelationId()
|
||
{
|
||
// Construct with the real AuthenticatedUser shape (read Commons/…/AuthenticatedUser).
|
||
var envelope = /* new ManagementEnvelope(user, command: new object(), "corr-1") */ default(ManagementEnvelope);
|
||
// serialize → deserialize → assert CorrelationId round-trips.
|
||
}
|
||
}
|
||
```
|
||
Flesh out one old-shape + one unknown-field + one round-trip case per listed record (read each record first; the literals above must match real constructor signatures — that is part of the task, and any mismatch found IS the drift this suite exists to surface).
|
||
2. Run, expect FAIL (missing Newtonsoft reference → compile error):
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~ClusterClientContractLock"
|
||
```
|
||
3. Add the `PackageVersion` to `Directory.Packages.props` and the `PackageReference` to the Commons.Tests csproj; finish the test bodies.
|
||
4. Run, expect PASS:
|
||
```bash
|
||
dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~ClusterClientContractLock"
|
||
```
|
||
5. Commit:
|
||
```bash
|
||
git add Directory.Packages.props tests/ZB.MOM.WW.ScadaBridge.Commons.Tests
|
||
git commit -m "test(commons): contract-lock tests for top ClusterClient records — enforce additive-only evolution under version skew (arch-review 08 §1.8)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Docs-code drift + conventions cleanup batch (Low findings, consolidated)
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 2, 3, 4, 5, 6, 7, 8, 9, 11 (NOT 1 — both edit CLAUDE.md)
|
||
**Files:**
|
||
- Modify: `docs/requirements/Component-Commons.md` (layout tree, lines ~293–294)
|
||
- Modify: `docs/requirements/Component-ConfigurationDatabase.md` (repositories section)
|
||
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj` (line 22)
|
||
- Modify: `README.md` (line ~111, the "One doc per component" claim)
|
||
- Modify: `docs/components/README.md` (index)
|
||
- Modify: `CLAUDE.md` (Document Conventions section)
|
||
|
||
Concrete edits (each verified against current file state):
|
||
|
||
1. **Component-Commons.md — add `Observability/`** (§1.1, narrowed: `Serialization/` and `Validators/` are already documented at lines 293–294; only `Observability/` — which contains `ScadaBridgeTelemetry.cs` — is missing). In the layout tree, before the `├── Serialization/` line add:
|
||
```
|
||
├── Observability/ # ScadaBridgeTelemetry (ActivitySource/metrics names)
|
||
```
|
||
(Adjust the comment after reading `src/ZB.MOM.WW.ScadaBridge.Commons/Observability/ScadaBridgeTelemetry.cs` — describe what it actually holds.)
|
||
2. **Component-ConfigurationDatabase.md — document the site-side repository exception** (§1.3). In the section stating repository implementations live in Configuration Database, add:
|
||
> Site-side exception: read-only SQLite-backed implementations of shared repository interfaces live in `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Repositories/` (currently `SiteExternalSystemRepository`) — same Commons interface, site-local store, writes throw `NotSupportedException` because site artifacts are managed exclusively by deployment from Central.
|
||
|
||
(If Task 6 has not run yet, mention `SiteNotificationRepository` too and let Task 6's doc step remove it; if running after Task 6, list only the external-system repository.)
|
||
3. **ManagementService.csproj line 22** — change `..\ZB.MOM.WW.ScadaBridge.InboundAPI\ZB.MOM.WW.ScadaBridge.InboundAPI.csproj` to `../ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj` (§1.4 cosmetic; every other reference in the repo uses `/`).
|
||
4. **README.md ~line 111** (§4): replace the claim "One doc per component (plus the shared TreeView)" with an accurate scope, e.g.:
|
||
> Reference docs exist for 24 of the 27 components (plus the shared TreeView); ScriptAnalysis (#25), KpiHistory (#26), and DelmiaNotifier (#27) are pending — tracked in `docs/plans/2026-07-08-deferred-work-register.md`.
|
||
5. **docs/components/README.md**: add the same three-pending note to the index so the two lists can't drift independently.
|
||
6. **CLAUDE.md Document Conventions** (§4 DelmiaNotifier): add one bullet:
|
||
> - Exception: DelmiaNotifier (#27) is an external client tool, not a cluster component; its spec is the project README (`src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`) plus `docs/plans/2026-06-26-delmia-recipe-notifier-design.md` — there is intentionally no `Component-DelmiaNotifier.md`.
|
||
7. Verify (config/docs task — verification replaces the failing test):
|
||
```bash
|
||
dotnet build src/ZB.MOM.WW.ScadaBridge.ManagementService # csproj separator change still builds
|
||
grep -n "Observability" docs/requirements/Component-Commons.md # expect: 1 hit in the tree
|
||
grep -rn '\\\\ZB.MOM' src --include="*.csproj" # expect: no hits
|
||
grep -n "24 of the 27\|pending" README.md docs/components/README.md # expect: scoped claim present
|
||
grep -n "Component-DelmiaNotifier" CLAUDE.md # expect: the exception bullet
|
||
```
|
||
8. Commit:
|
||
```bash
|
||
git add docs/requirements/Component-Commons.md docs/requirements/Component-ConfigurationDatabase.md src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj README.md docs/components/README.md CLAUDE.md
|
||
git commit -m "docs: close arch-review 08 drift batch — Commons Observability folder, site-repo exception, csproj separator, docs/components scope, DelmiaNotifier convention exception"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Deferred-work register — triage the 23-item inventory into a tracked list
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** 1, 2, 3, 4, 5, 6, 7, 8, 9 (write after 10 only if you want the README pointer target to exist first — create this file before or simultaneously; the README in Task 10 points here)
|
||
**Files:**
|
||
- Create: `docs/plans/2026-07-08-deferred-work-register.md`
|
||
|
||
Turn the review's §3 inventory (23 items) into the explicit tracked register the review asked for: every item gets a row with **status** (`fix-now → plan NN` / `deferred`), **owner/where-noted**, **rationale**, and a **revisit trigger** (the concrete condition that reopens it). This is the single place the "consciously logged" deferrals live from now on; new deferrals get appended here instead of scattering.
|
||
|
||
1. Create `docs/plans/2026-07-08-deferred-work-register.md` with this exact structure and content (source: `archreview/08-conventions-tests-underdeveloped.md` §3; carry each item's file/line citation over):
|
||
|
||
```markdown
|
||
# Deferred-Work Register (established 2026-07-08, from architecture review 08)
|
||
|
||
Single tracked list of consciously-deferred work. Rules: every deferral gets a row
|
||
(rationale + revisit trigger); fix-now items reference the archreview plan that owns
|
||
them and are removed from this table when that plan's task lands.
|
||
|
||
## Fix-now (owned by archreview plans)
|
||
| # | Item | Owner |
|
||
|---|------|-------|
|
||
| 1 | Transport: Area membership doesn't travel in bundles (EntitySerializer.cs:251,518) | PLAN-05 |
|
||
| 2 | CLI.Tests not in slnx | PLAN-08 Task 1 |
|
||
| 3 | AuditLog reconciliation dials site NodeA only (SiteEnumerator.cs:32,64) | PLAN-04 |
|
||
| 4 | Options validation missing on ~12 components | PLAN-08 Tasks 2–5 |
|
||
| 5 | SmsConfiguration.MaxRetryCount stored but not honored | PLAN-06 |
|
||
| 6 | Role-check case-sensitivity asymmetry (ManagementActor.cs:1910) | PLAN-07 |
|
||
| 23 | Vestigial site-side notification-list surface | PLAN-08 Task 6 |
|
||
|
||
## Deferred (with rationale + revisit trigger)
|
||
| # | Item | Where noted | Rationale for deferral | Revisit trigger |
|
||
|---|------|-------------|------------------------|-----------------|
|
||
| 7 | SecuredWrite audit rows leave SourceNode NULL | CLAUDE.md Security | Logged follow-up; low-volume channel | First per-node forensic query against secured writes |
|
||
| 8 | Hash-chain tamper evidence (T1); CLI verify-chain is a no-op stub | audit-log roadmap :12 | v1.x by locked decision; append-only DB roles are the control | Compliance requirement for cryptographic tamper evidence |
|
||
| 9 | Parquet audit archival (T2); endpoint returns 501 | AuditEndpoints.cs:204 | v1.x; 501 + CLI messaging are honest | AuditLog partition volume nears retention ceiling |
|
||
| 10 | Aggregated live alarm stream for Alarm Summary | m7 design :252 | Snapshot fan-out acceptable at current instance counts | Alarm Summary latency complaints or >~50 instances/site |
|
||
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
|
||
| 12 | Native-alarm-source-override CSV import | m7 follow-ups | Parity gap only; attribute CSV shipped | First bulk native-alarm rollout request |
|
||
| 13 | WaitForAttribute quality-gated mode | ScriptRuntimeContext.cs:405 | Planned enhancement per spec §4.2 | First script needing Good-only waits |
|
||
| 14 | WaitForAttribute in Test-Run sandbox | waitfor-deferred :222 | Test-Run parity gap; production unaffected | Author feedback on Test-Run fidelity |
|
||
| 15 | BrowseNext final-page signal not surfaced | BrowseCommands.cs:34 | One wasted round-trip | UX complaint on browse paging |
|
||
| 16 | StubOpcUaClient throws on browse | m7 design :245 | Limits offline UI coverage only | Next browse/search UI regression |
|
||
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
|
||
| 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) |
|
||
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
|
||
| 20 | Deployment EXPIRED-row purge | DeploymentManagerRepository.cs:310 | Read path already compensates | EXPIRED rows visible in ops queries |
|
||
| 21 | SiteAuditBacklogReporter threshold consolidation | SiteAuditBacklogReporter.cs:28 | Config-shape cleanup only | Next SqliteAuditWriterOptions change |
|
||
| 22 | KPI history hourly rollups | Component-KpiHistory.md | YAGNI; 90-day retention bounds table | KpiSample query latency on dashboards |
|
||
|
||
## New deferrals from review 08 (this plan)
|
||
| Item | Rationale | Revisit trigger |
|
||
|------|-----------|-----------------|
|
||
| Communication → HealthMonitoring layering (ICentralHealthAggregator consumed by CentralCommunicationActor.cs:351) | Moving the interface + SiteHealthState to Commons ripples across 5 projects for a cosmetic inversion | Next breaking change to ICentralHealthAggregator |
|
||
| docs/components reference docs for ScriptAnalysis, KpiHistory, DelmiaNotifier | Reference docs are substantial (StyleGuide-conformant); README claim scoped instead (PLAN-08 Task 10) | Next doc-writing session touching those components |
|
||
| Test-coverage backfill: SiteCallAudit.Tests (31 tests/1.6k LOC), DeploymentManager.Tests | No defect identified; coverage partly lives in ManagementService/Host/Integration suites | First regression escaping either component |
|
||
| Failover-timing + broader perf envelope (S&F drain rate, per-subscriber backpressure) | Needs PLAN-01 two-node rig; placeholder harness shipped (PLAN-08 Task 8) | PLAN-01 rig landing |
|
||
```
|
||
2. Verify (docs task):
|
||
```bash
|
||
grep -c "^| " docs/plans/2026-07-08-deferred-work-register.md
|
||
```
|
||
Expected: ≥ 30 table rows (7 fix-now + 16 deferred + 4 new + headers).
|
||
3. Commit:
|
||
```bash
|
||
git add docs/plans/2026-07-08-deferred-work-register.md
|
||
git commit -m "docs(plans): deferred-work register — triage the 23-item arch-review inventory into fix-now (plan-owned) vs deferred-with-rationale"
|
||
```
|
||
|
||
---
|
||
|
||
## Dependencies on other plans
|
||
|
||
- **Plan 01 (Cluster/Host/Failover):** Task 8 is a placeholder that documents the failover-timing measurement protocol; the actual two-node kill rig is Plan 01's (overall review P2-10). When Plan 01's rig lands, un-skip `FailoverTimingTests` and wire it in. No file overlap expected, but Plan 01 may touch `Host/Program.cs` (Task 4 also edits it — coordinate merge order).
|
||
- **Plan 05 (Templates/Deploy/Transport):** owns the Transport `AreaName: null` contradiction (§3 item 1 / §4). Plan 05 also touches `Transport/`; Task 5 here only touches `Transport/ServiceCollectionExtensions.cs` + a new validator file — small merge-conflict surface.
|
||
- **Plan 04 (Data/Audit):** owns AuditLog NodeA-only reconciliation (§3 item 3).
|
||
- **Plan 06 (Edge integrations):** owns `SmsConfiguration.MaxRetryCount` (§3 item 5).
|
||
- **Plan 07 (UI/Management/Security):** owns role-casing asymmetry (§3 item 6). Plan 07 may also touch `ManagementActor.cs`; Task 4 here touches only `ManagementService/ServiceCollectionExtensions.cs` + csproj.
|
||
- The register (Task 11) cites plan numbers 04/05/06/07 — content-only references, no execution dependency.
|
||
|
||
## Execution order
|
||
|
||
1. **Task 1 first** (slnx) — it changes what `dotnet test ZB.MOM.WW.ScadaBridge.slnx` covers, so every later task's solution-level verification includes CLI.Tests.
|
||
2. **Tasks 2, 4, 5, 7, 8, 9, 11 in parallel** (disjoint files).
|
||
3. **Task 3 then Task 6 sequentially** (both live in the SiteRuntime project; either order works, but running 6 after 3 keeps the CompositionRoot test churn in one direction).
|
||
4. **Task 10 last among docs** (it edits CLAUDE.md — serialize with Task 1's CLAUDE.md edit — and its README pointer targets the Task 11 register, so run after 11).
|
||
5. Finish with a full `dotnet build ZB.MOM.WW.ScadaBridge.slnx && dotnet test ZB.MOM.WW.ScadaBridge.slnx` — expect all 30 test projects (now including CLI.Tests' ~279) green.
|