Files
ScadaBridge/docs/plans/2026-07-19-localdb-adoption-phase1.md
T
Joseph Doherty 1556ae04e4 docs(localdb): amend Phase 1 plan - split Task 5 (event-log id change)
Execution review against the code found the integer site_events.id is
load-bearing in three places the original Task 5 did not list:

  - keyset cursor      EventLogQueryService.cs:70,141,153  (id > $afterId)
  - storage-cap purge  EventLogPurgeService.cs:164         (ORDER BY id ASC)
  - wire contracts     EventLogEntry.Id, both ContinuationTokens (long)
                       cross the site<->central Akka boundary

A GUID PK against the existing cursor silently drops rows, and against the
purge deletes arbitrary rows instead of the oldest. Task 5 is therefore split
into 5a (writer, standard) and 5b (read path + DTOs, high-risk), with 5b's
full file set spelled out and Task 10 gated on it.

Also recorded: Task 4's real registration site, and that both legacy DB paths
are CWD-relative and unset in docker today (so they live outside the data
volume and are already lost on container recreate - Phase 1 fixes that).

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-19 02:34:38 -04:00

28 KiB

ScadaBridge LocalDb Adoption — Phase 1 Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Site node pairs stop losing operation-tracking and site-event state on failover: both stores move into one ZB.MOM.WW.LocalDb-managed database with optional (default-OFF) 2-node replication, live-proven on the docker rig.

Architecture: One consolidated LocalDb file per site node (LocalDb:Path/app/data/site-localdb.db). OperationTracking + site_events become RegisterReplicated tables. Stores keep their public interfaces and their own connection-management style, but obtain connections from ILocalDb.CreateConnection() (the lib registers the zb_hlc_next() UDF per-connection — a foreign SqliteConnection writing to a registered table FAILS, so every write path must go through the lib). Replication (initiator = node-a dialing node-b:8083, bearer-key-gated passive endpoint) is config-gated and default OFF. Design doc: ~/Desktop/scadaproj/docs/plans/2026-07-19-scadabridge-localdb-design.md.

Tech Stack: .NET 10, ZB.MOM.WW.LocalDb 0.1.0 + .Replication + .Contracts (Gitea feed), Microsoft.Data.Sqlite, gRPC on the existing site Kestrel (8083), xunit.

Execution configuration (saved for later execution):

  • Resume with /superpowers-extended-cc:executing-plans docs/plans/2026-07-19-localdb-adoption-phase1.md — the co-located .tasks.json carries the dependency graph.
  • Model: Opus for every implement subagent (model: "opus" on Agent dispatch). Review chain per task classification (high-risk tasks get serial spec + code review, small tasks a single Sonnet/Haiku code-reviewer pass).
  • Dispatch tasks in waves — every task in a wave runs concurrently (file sets are disjoint; verified below). Parallel implementers editing one repo MUST use worktree isolation (Agent isolation: "worktree") — concurrent git in a shared worktree races destructively.
Wave Tasks (concurrent)
1 Task 1 (packages)
2 Task 2 (schema helpers)
3 Task 3 (composition root)
4 Task 4 (tracking store) ∥ Task 5a (event writer) ∥ Task 7 (replication wiring)
5 Task 5b (event read path) ∥ Task 6 (migrator) ∥ Task 8 (auth interceptor) ∥ Task 9 (health signal)
6 Task 10 (convergence test) ∥ Task 11 (rig config)
7 Task 12 (live gate — serial, on the real rig)
8 Task 13 (docs) ∥ Task 14 (Phase 2 planning)

Hard constraints learned from the code (do not rediscover):

  • AddZbLocalDb(config, onReady) is one-DB-per-process, first-registration-wins. Table DDL must run inside onReady before RegisterReplicated; rows inserted before registration are never captured/snapshotted.
  • site_events.id must change from INTEGER AUTOINCREMENT to TEXT (GUID) — two nodes minting the same autoincrement id silently overwrite each other under LWW.
  • The lib's passive sync endpoint does not verify the ApiKey; ScadaBridge must gate /localdb_sync.v1.LocalDbSync/ itself (server interceptor, fixed-time compare).
  • MapZbLocalDbSync() returns IEndpointRouteBuilder (not the gRPC convention builder), so auth cannot be attached via the return value — hence the interceptor.
  • SQLitePCLRaw must stay pinned at 2.1.12 (family-wide advisory; ScadaBridge already pins it — do not remove the pin).

Task 1: Add LocalDb packages to the build

Classification: small Estimated implement time: ~3 min Parallelizable with: none (everything depends on it)

Files:

  • Modify: Directory.Packages.props (package versions block, near the Microsoft.Data.Sqlite entry at line ~33)
  • Modify: NuGet.config (packageSourceMapping, dohertj2-gitea block)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ZB.MOM.WW.ScadaBridge.SiteEventLogging.csproj

Step 1: Add central package versions:

<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.LocalDb.Contracts" Version="0.1.0" />

Step 2: Add feed mapping patterns to the dohertj2-gitea packageSource block in NuGet.config:

<package pattern="ZB.MOM.WW.LocalDb" />
<package pattern="ZB.MOM.WW.LocalDb.*" />

Step 3: Add <PackageReference Include="ZB.MOM.WW.LocalDb" /> to SiteRuntime + SiteEventLogging csproj; add ZB.MOM.WW.LocalDb and ZB.MOM.WW.LocalDb.Replication to Host csproj.

Step 4: Run dotnet build ZB.MOM.WW.ScadaBridge.slnx — expect success, 0 warnings (TreatWarningsAsErrors). Verify restore came from the Gitea feed, not a 404.

Step 5: Commit: git commit -m "feat(localdb): reference ZB.MOM.WW.LocalDb 0.1.0 packages"


Task 2: Shared schema helpers (tracking + events, GUID PK)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingSchema.cs
  • Create: src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogSchema.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingSchemaTests.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/SiteEventLogSchemaTests.cs

The DDL must be callable from the Host's AddZbLocalDb onReady (which owns table creation), from the stores' existing idempotent init, and from tests. Extract each table's DDL into a static Apply(SqliteConnection) helper in its owning project.

Step 1: Write failing tests — each test opens an in-memory SqliteConnection, calls Apply twice (idempotency), and asserts via PRAGMA table_info that (a) the table exists, (b) site_events.id is TEXT and pk == 1 with no autoincrement, (c) OperationTracking columns match the current store schema including SourceNode.

Step 2: Run the two test files — expect FAIL (types don't exist).

Step 3: Implement. OperationTrackingSchema.Apply = verbatim move of the DDL + AddColumnIfMissing("SourceNode", …) logic from OperationTrackingStore.InitializeSchema (OperationTrackingStore.cs:74-116). SiteEventLogSchema.Apply = the DDL from SiteEventLogger.cs:146-160 changed to:

CREATE TABLE IF NOT EXISTS site_events (
    id TEXT NOT NULL PRIMARY KEY,
    timestamp TEXT NOT NULL,
    event_type TEXT NOT NULL,
    severity TEXT NOT NULL,
    instance_id TEXT,
    source TEXT NOT NULL,
    message TEXT NOT NULL,
    details TEXT
);
-- same four indexes as today

Keep both helpers free of any LocalDb dependency (plain Microsoft.Data.Sqlite) so the owning projects don't need new references for this task alone. Rewire OperationTrackingStore.InitializeSchema and SiteEventLogger.InitializeSchema to delegate to the helpers (behavior-preserving for tracking; the events PK change lands here and is completed by Task 5's writer change).

Step 4: Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests and …SiteEventLogging.Tests — new tests PASS. Pre-existing SiteEventLogging tests that insert rows will now fail on the missing id — fix them in this task by supplying GUID ids where the test writes rows directly (the production writer changes in Task 5; if a production-code insert breaks compilation of tests, coordinate: this task may leave SiteEventLogging.Tests red ONLY for writer-path tests fixed in Task 5 — prefer landing Tasks 2+5 in one PR-sized push).

Step 5: Commit.


Task 3: Register LocalDb in the site composition root

Classification: high-risk (composition-root wiring — the family's known failure class) Estimated implement time: ~5 min Parallelizable with: none

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs (insert after AddSiteRuntime, ~line 51)
  • Modify: docker/site-a-node-a/appsettings.Site.json (+ the other five site-node appsettings): add "LocalDb": { "Path": "/app/data/site-localdb.db" }
  • Test: tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs

Step 1: Write the failing DI-graph test. Follow the existing pattern from the ScadaBridge#22 fix (Host.Tests has WebApplicationFactory-style DI tests over the real registration): build a ServiceCollection via SiteServiceRegistration.Configure with an in-memory config supplying LocalDb:Path = temp file, resolve ILocalDb, and assert ReplicatedTables contains OperationTracking and site_events. This is the test that would have caught the Secrets inert-replicator bug — it builds the real graph.

Step 2: Run it — FAIL (no registration).

Step 3: Implement SiteLocalDbSetup:

using Microsoft.Data.Sqlite;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;

namespace ZB.MOM.WW.ScadaBridge.Host;

/// <summary>
/// onReady for the consolidated site LocalDb: creates the replicated tables and
/// opts them into capture. Runs before any store resolves ILocalDb; DDL must
/// precede RegisterReplicated because pre-registration rows are never captured.
/// </summary>
public static class SiteLocalDbSetup
{
    public static void OnReady(ILocalDb db)
    {
        using (var conn = db.CreateConnection())
        {
            OperationTrackingSchema.Apply(conn);
            SiteEventLogSchema.Apply(conn);
        }
        db.RegisterReplicated("OperationTracking");
        db.RegisterReplicated("site_events");
    }
}

In SiteServiceRegistration.Configure, after AddSiteRuntime (line ~51):

// Consolidated site LocalDb — OperationTracking + site_events live here as
// replicated tables (design: scadaproj docs/plans/2026-07-19-scadabridge-localdb-design.md).
// Replication itself is registered in Program.cs (needs the WebApplication gRPC host);
// storage is unconditional — with no peer configured this is just a local SQLite DB.
services.AddZbLocalDb(config, SiteLocalDbSetup.OnReady);

Step 4: Run the wiring test — PASS. Run dotnet build — 0 warnings.

Step 5: Commit.


Task 4: Rewire OperationTrackingStore onto ILocalDb connections

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 5, Task 7

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingStore.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs:74 (the IOperationTrackingStore registration; AddSiteRuntime's connection-string parameter becomes vestigial for this store — leave the parameter alone, it still feeds the site config DB)
  • Test: tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/ (existing store tests)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs:433 (Site_Resolves_IOperationTrackingStore — it overrides the connection string to a temp path because the store opens eagerly in its ctor; that override changes shape once the store takes ILocalDb)

Step 1: Constructor gains ILocalDb localDb; _writeConnection = localDb.CreateConnection() replaces new SqliteConnection(_connectionString) + Open() (verified in the lib, SqliteLocalDb.cs:83-98: CreateConnection returns an open connection with per-connection pragmas — synchronous, busy_timeout, foreign_keys=ON — and the zb_hlc_next UDF registered; do NOT call Open() on it again). Reader paths that open ad-hoc connections from _connectionString switch to localDb.CreateConnection() too (reads don't fire triggers, but one connection source is one less config knob). OperationTrackingOptions.ConnectionString becomes unused by the store — keep the options class but drop the store's dependency; leave a validator relaxation only if the DI-graph test demands it.

Step 2: Update existing store tests to construct via a real ILocalDb over a temp file (the lib package is test-referenceable) instead of a raw connection string. Schema now comes from OperationTrackingSchema.Apply in setup.

Step 3: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests — PASS.

Step 4: Commit.


Task 5a: Rewire SiteEventLogger onto ILocalDb + mint GUID ids

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 4, Task 7

Amended 2026-07-19 (execution review). The original Task 5 said "any ORDER BY id becomes ORDER BY timestamp" and listed 3 files. Code recon found the integer id is load-bearing in three separate places beyond the writer — a keyset cursor, the storage-cap purge, and long-typed DTOs that cross the site↔central Akka boundary. Task 5 is therefore split: 5a = the writer (this task, self-contained), 5b = the read/purge/contract change (high-risk, its own task). Do not fold 5b back into 5a.

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ServiceCollectionExtensions.cs (AddSiteEventLogging registration)
  • Test: tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/ (writer-path tests only)

Step 1: SiteEventLogger constructor gains ILocalDb localDb; its single owned _connection (SiteEventLogger.cs:34,62-65) becomes localDb.CreateConnection() — already open, pragma-configured, HLC-UDF-registered; do not call Open() again. Delete the connectionStringOverride seam (:50) rather than preserving it: a raw connection lacks zb_hlc_next(), so any test writing through the override against a registered table now fails closed. Switch the test fixture to a real ILocalDb over a temp file.

Step 2: Preserve the internal WithConnection seam (:104, :123) — EventLogQueryService and EventLogPurgeService both depend on it deliberately (their ctors take the concrete SiteEventLogger, not the interface). Its body changes connection source only; the signature and locking semantics stay identical, so 5b's consumers keep compiling.

Step 3: The INSERT (:237-240) gains an id column bound to Guid.NewGuid().ToString("N"), minted per event in the writer loop. ISiteEventLogger.LogEventAsync's signature is unchanged — all 18 fire-and-forget call sites are untouched.

Step 4: Fix writer-path tests from Task 2. Run dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests — writer tests PASS. Query/purge tests may be red here; 5b closes them. Note that in the commit message.

Step 5: Commit.


Task 5b: Migrate the event-log read path off integer ids

Classification: high-risk (cross-cluster data contract + silent-data-loss paging bug) Estimated implement time: ~5 min Parallelizable with: Task 6, Task 8, Task 9 Blocked by: Task 5a

The GUID PK breaks three integer-id assumptions. All three must move together — shipping any one alone is a live bug.

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs (cursor :68-71, ORDER BY id ASC :141, GetInt64(0) :153, token mint :181)
  • Modify: src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogPurgeService.cs (storage-cap delete :162-166)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryResponse.cs (EventLogEntry.Id longstring; ContinuationToken long?string?)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs (ContinuationToken long?string?)
  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs:3127 (passes null today — verify it still compiles)
  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor:247,268,271 (_continuationToken type)
  • Test: tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/ (query + purge)

Step 1: Write the failing tests first.

  • Paging: insert N events with known timestamps, page through with PageSize < N, assert every row is returned exactly once across pages and in chronological order. This test fails loudly against a naive id > $afterId GUID cursor — that is the point.
  • Ordering: rows come back oldest-first regardless of GUID values.
  • Purge: with the storage cap tripped, the oldest rows are deleted, not arbitrary ones.

Step 2: Run — FAIL.

Step 3: Implement.

  • Cursor → composite keyset on (timestamp, id), since timestamp alone is not unique: WHERE (timestamp > $afterTs) OR (timestamp = $afterTs AND id > $afterId), ORDER BY timestamp ASC, id ASC. The continuation token encodes both — use "{timestamp}|{id}" as the string? token; parse defensively and treat a malformed token as "start from the beginning" rather than throwing.
  • Purge (:162-166) → ORDER BY timestamp ASC instead of ORDER BY id ASC. (The retention purge at :128 already filters on timestamp and is unaffected.)
  • DTOsId and both ContinuationTokens become string/string?; reader uses GetString(0).

Step 4: Wire-compat check. These DTOs cross the site↔central Akka boundary (SiteCommunicationActor.cs:193CommunicationService.cs:304 → ManagementActor / CentralUI). Both sides ship in the same deployable and the rig redeploys as a unit, so no rolling-upgrade shim is needed — but state that explicitly in the commit message, and confirm no serializer manifest/binding pins these types by name+type (grep the Akka serialization setup). If a binding exists, stop and surface it.

Step 5: dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests + full solution build (0 warnings) — PASS. Commit.


Task 6: One-time legacy data migrator

Classification: high-risk (data migration) Estimated implement time: ~5 min Parallelizable with: Task 8, Task 9 (needs Tasks 4-5 done)

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbSetup.cs (call migrator at the end of OnReady)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs

Semantics (write the tests first, one per bullet):

  • Legacy files located from the OLD config keys (ScadaBridge:OperationTracking:ConnectionString, ScadaBridge:SiteEventLog:DatabasePath); missing file ⇒ no-op.
  • Both keys are unset in every docker appsettings today, so they fall back to the code defaults "Data Source=site-tracking.db" (OperationTrackingOptions.cs:14) and "site_events.db" (SiteEventLogOptions.cs:10) — CWD-relative, i.e. /app/site-tracking.db and /app/site_events.db, NOT /app/data/. Resolve relative paths against the process CWD; do not assume the data volume. (Consequence worth knowing: both DBs live outside the mounted volume today and are already lost on every container recreate — so on the rig the migrator will usually find nothing, and that is a legitimate no-op, not a failure. Phase 1 incidentally fixes that data-loss bug by consolidating into /app/data/site-localdb.db.)
  • Runs after RegisterReplicated so migrated rows enter the oplog and replicate.
  • Tracking rows copy with INSERT OR IGNORE (same TEXT PK ⇒ idempotent).
  • Legacy site_events rows get deterministic ids — "mig-{NodeName}-{legacyId}" — NOT fresh GUIDs, so a crashed-then-rerun migration cannot duplicate events (INSERT OR IGNORE on the deterministic key). NodeName from ScadaBridge:Node:NodeName.
  • The whole copy runs in one ILocalDb.BeginTransactionAsync transaction; the legacy file is renamed to <name>.migrated only after commit. Migration failure throws ⇒ host fails to boot (no half-state; legacy files untouched on rollback).
  • Second boot (file already .migrated) ⇒ no-op.

Run the migrator tests + full Host.Tests; commit.


Task 7: Replication registration + sync endpoint (default OFF)

Classification: high-risk (wiring + endpoint exposure) Estimated implement time: ~4 min Parallelizable with: Task 4, Task 5 (needs Task 3 only; touches Program.cs, disjoint from the store files)

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/Program.cs site block (:515-536)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs (extend)

Step 1: In the site block: builder.Services.AddZbLocalDbReplication(builder.Configuration); next to AddGrpc() (line ~516), and app.MapZbLocalDbSync(); next to the existing MapGrpcService<SiteStreamGrpcServer>() (line ~536). Kestrel already serves h2c HTTP/2 on 8083 — no listener changes.

Step 2: Extend the DI-graph test: with no LocalDb:Replication:PeerAddress, the host builds and the initiator idles (resolve ISyncStatus, assert not connected, no throw). This is the "replication is default-OFF and inert-but-not-broken" pin.

Step 3: Build + Host.Tests green; commit.


Task 8: Bearer auth interceptor for the sync endpoint

Classification: high-risk (security) Estimated implement time: ~5 min Parallelizable with: Task 6, Task 9

Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Host/LocalDbSyncAuthInterceptor.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/Program.cs (site AddGrpc gains options.Interceptors.Add<LocalDbSyncAuthInterceptor>())
  • Test: tests/ZB.MOM.WW.ScadaBridge.Host.Tests/LocalDbSyncAuthInterceptorTests.cs

The lib's passive service does not verify keys (documented). Fail-closed server interceptor, scoped by method path:

/// <summary>
/// Gates the LocalDb passive sync endpoint. The library deliberately leaves
/// inbound auth to the host. Scoped to /localdb_sync.v1.LocalDbSync/ so the
/// existing SiteStream service is untouched. Fail-closed: no configured key
/// means NO sync stream is accepted, authenticated or not.
/// </summary>
public sealed class LocalDbSyncAuthInterceptor : Interceptor
{
    private const string ServicePrefix = "/localdb_sync.v1.LocalDbSync/";
    // read via IOptions<ReplicationOptions> — verified: AddZbLocalDbReplication binds
    // section "LocalDb:Replication" with ValidateOnStart; ApiKey is string? (null =
    // no key configured). Same key on both nodes; ${secret:} refs resolve through
    // the existing pre-host expander.
    // compare with CryptographicOperations.FixedTimeEquals over UTF8 bytes;
    // non-sync methods pass through untouched.
}

Tests (TDD, write first): non-sync path passes with no key; sync path with no configured key → PermissionDenied; wrong bearer → PermissionDenied; correct bearer → passes through. Use the interceptor directly with a fake ServerCallContext (Grpc.Core.Testing) — no live channel needed. Commit.


Task 9: Surface ISyncStatus as site health signal

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 6, Task 8

Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs (health wiring — follow the AddSiteEventLogHealthMetricsBridge precedent at :76)
  • Test: extend SiteLocalDbWiringTests

Bridge ISyncStatus (Connected, OplogBacklognullable, null must surface as unknown, never as 0) into the site health report the same way FailedWriteCount is bridged. Metrics need no work — the ZB.MOM.WW.LocalDb.Replication meter flows through the existing AddZbTelemetry Prometheus export. Test: bridge registered + resolvable. Commit.


Task 10: Two-node in-process convergence test

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 11 Blocked by: Task 6, Task 8, Task 5b (scenario 3 reads events back through the query path)

Files:

  • Create: tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairConvergenceTests.cs

Model on the LocalDb lib's own integration suite (~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/tests/ — two real SQLite DBs over a real gRPC pipe): two hosts running the ScadaBridge site schema (SiteLocalDbSetup.OnReady), replication enabled between them (loopback ports, shared ApiKey through the real interceptor). Scenarios:

  1. Tracking row written on A → readable on B ≤ a few flush intervals.
  2. Same op updated on both nodes → both converge to one winner (LWW).
  3. Events logged on both nodes → union visible on both (GUID ids, no collisions).
  4. B offline while A accumulates → B rejoins → convergence.

Offline, no docker. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.IntegrationTests --filter "FullyQualifiedName~LocalDbSitePairConvergence" — PASS. Commit.


Task 11: Docker rig enablement (site-a pair)

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 10

Files:

  • Modify: docker/site-a-node-a/appsettings.Site.json — add "Replication": { "PeerAddress": "http://<site-a-node-b container name>:8083", "ApiKey": "<dev key>" } under LocalDb (take exact container hostname from docker/docker-compose.yml)
  • Modify: docker/site-a-node-b/appsettings.Site.json"Replication": { "ApiKey": "<same dev key>" } (passive; key for inbound verification)
  • Site-b/site-c pairs stay unreplicated (flag-off posture proven side-by-side)

Plain dev key in rig config is acceptable (matches the rig's existing posture); note in the compose README that production uses a ${secret:} ref. Commit.


Task 12: Live gate on the docker rig

Classification: standard (verification, no code) Estimated implement time: ~10 min wall (mostly waiting on the rig) Parallelizable with: none

Per docker/README.md + memory gotchas (restart central-a+b together; Central needs ApiKeyPepper):

  1. bash docker/deploy.sh — rebuild + redeploy the 8-node cluster.
  2. Generate tracking + event activity (existing rig flows / CLI).
  3. On both site-a nodes: sqlite3 /app/data/site-localdb.db "SELECT count(*) FROM site_events" etc. — counts converge; spot-check row equality.
  4. bash docker/failover-drill.sh — after takeover, the new active node serves complete tracking/event history from before the flip.
  5. Check /metrics on 8084 for localdb_* instruments; check the dead-letter table is empty.
  6. Verify site-b (replication off) boots and behaves identically to before — the default-OFF pin, live.

Evidence (commands + output) goes in the PR/commit message. Any failure here loops back to the offending task — do not mark this plan done with a red gate.


Task 13: Documentation truth pass

Classification: trivial Estimated implement time: ~4 min Parallelizable with: Task 14

  • ScadaBridge CLAUDE.md: consolidated-DB note, config keys, replication flag posture, deleted config keys (old tracking/eventlog paths) marked migration-only.
  • scadaproj CLAUDE.md LocalDb component row: "no app adoption yet" → ScadaBridge Phase 1 adopted (state exactly what is and is not live, per the component-status-honesty convention).
  • SiteEventLogger XML doc: remove the now-false "Not replicated to standby. On failover, the new active node starts a fresh log."

Commit; push per repo convention.


Task 14: Phase 2 gate — plan the bespoke-replicator migration

Classification: standard (planning only) Estimated implement time: planning session

Blocked by: Task 12 live gate PASS.

Phase 2 (config tables + sf_messages into the consolidated DB; delete SiteReplicationActor + StoreAndForward ReplicationService) gets its own implementation plan written against post-Phase-1 reality. Inputs: the design doc §1 Phase 2, the bespoke replicator's test intents (port before deletion), the N5 bounded-duplicate acceptance, and any oplog-cap/churn observations from the Phase-1 rig soak. Do not start Phase 2 work without that plan.


Execution notes

  • Family conventions bind: TreatWarningsAsErrors, red-first tests, container-built DI-graph tests for all wiring, dotnet test offline-green.
  • Lib assumptions verified against source 2026-07-19 (~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/): CreateConnection() returns an open, pragma-configured, UDF-registered connection (SqliteLocalDb.cs:83-98); ReplicationOptions binds from LocalDb:Replication with ValidateOnStart, ApiKey nullable, and a passive node (empty PeerAddress) starts its SyncBackgroundService and immediately idles (ReplicationServiceCollectionExtensions.cs).
  • If any task needs files beyond its Files: block, that is a plan defect — surface it rather than silently expanding scope.