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

56 KiB
Raw Blame History

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.

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 CommunicationHealthMonitoring 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):

    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:

     <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:

    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:

    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:
    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):
    dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "FullyQualifiedName~CommunicationOptionsValidator"
    
  3. Implement CommunicationOptionsValidator (positive-duration checks for every TimeSpan on CommunicationOptionsDeploymentTimeout, 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:
    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:
    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:
    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:
    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):
    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:
    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):
    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:
    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:
    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:
    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:
    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:
    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:
    services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection).ValidateOnStart();
    services.TryAddEnumerable(
        ServiceDescriptor.Singleton<IValidateOptions<TransportOptions>, TransportOptionsValidator>());
    
  4. Run, expect PASS:
    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:
    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:
    [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):
    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:
    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:
    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):
    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):
    dotnet test tests/ZB.MOM.WW.ScadaBridge.PerformanceTests --filter "FullyQualifiedName~SiteStreamThroughput"
    
  3. Commit:
    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:
    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:
    dotnet test tests/ZB.MOM.WW.ScadaBridge.PerformanceTests --filter "FullyQualifiedName~FailoverTiming"
    
    Expected: 1 skipped, 0 failed.
  3. Commit:
    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):
    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):
    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:
    dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~ClusterClientContractLock"
    
  5. Commit:
    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):

    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:

    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):

    # 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):

    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:

    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.