Files
ScadaBridge/archreview/plans/PLAN-08-conventions-tests.md
T
Joseph Doherty 3a21e77c03 docs(archreview): add cross-plan parallelization map for incomplete tasks + sync stale PLAN-04 status
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).
2026-07-09 09:53:05 -04:00

749 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** — T2T5 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 293294); 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 722 (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 1314), `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs` (lines 1522)
- 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 124127 — 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` + 23 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 2324), `src/ZB.MOM.WW.ScadaBridge.NotificationService/ServiceCollectionExtensions.cs` (lines 2021), `src/ZB.MOM.WW.ScadaBridge.ManagementService/ServiceCollectionExtensions.cs` (lines 1516)
- 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 4041), `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 715739) and `StoreSmtpConfigurationAsync` (lines ~741785). **Keep** `PurgeCentralOnlyNotificationConfigAsync` (lines 691713) 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 444451), `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Repositories/SiteRepositoryTests.cs` (~lines 185200), `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/ArtifactStorageTests.cs` (all `StoreNotificationListAsync`/`StoreSmtpConfigurationAsync`/`SiteNotificationRepository` usages: ~lines 125195)
- 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 &amp; 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 &lt;= 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 ~293294)
- 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 293294; 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 25 |
| 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.