Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
21 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.
Hard constraints learned from the code (do not rediscover):
AddZbLocalDb(config, onReady)is one-DB-per-process, first-registration-wins. Table DDL must run insideonReadybeforeRegisterReplicated; rows inserted before registration are never captured/snapshotted.site_events.idmust change fromINTEGER AUTOINCREMENTto 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()returnsIEndpointRouteBuilder(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 theMicrosoft.Data.Sqliteentry 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 afterAddSiteRuntime, ~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
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingStore.cs - Modify: wherever
AddSiteRuntimeregisters the store (followservices.AddSiteRuntimefromSiteServiceRegistration.cs:51into the SiteRuntime SCE) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/(existing store tests)
Step 1: Constructor gains ILocalDb localDb; _writeConnection = localDb.CreateConnection() replaces new SqliteConnection(_connectionString) + Open() (CreateConnection hands back an open, UDF-registered connection — verify against the lib and adjust if it returns closed). 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 5: Rewire SiteEventLogger + EventLogQueryService (GUID ids)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/SiteEventLogger.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/EventLogQueryService.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.SiteEventLogging/ServiceCollectionExtensions.cs(AddSiteEventLogging registration) - Test:
tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests/(existing suite)
Step 1: SiteEventLogger constructor gains ILocalDb localDb; its single owned _connection becomes localDb.CreateConnection(). Keep the connectionStringOverride test seam by preferring the override when non-null (tests may still use raw files for non-replicated scenarios) — but note overridden connections lack the HLC UDF, so tests that write must use the ILocalDb path once tables are registered; simplest is to switch the test fixture to a real ILocalDb and delete the override parameter if nothing else uses it.
Step 2: The insert statement gains an id column bound to Guid.NewGuid().ToString("N") (minted in the writer loop per event). EventLogQueryService (read-only) switches its connection source the same way; any ORDER BY id becomes ORDER BY timestamp (GUIDs don't sort chronologically — check the query paths and the UI paging contract).
Step 3: Fix remaining writer-path tests from Task 2. Full dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests — PASS.
Step 4: Commit.
Task 6: One-time legacy data migrator
Classification: high-risk (data migration) Estimated implement time: ~5 min Parallelizable with: none (needs Tasks 3-5)
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:SiteEventLogDatabasePath); missing file ⇒ no-op. - Runs after
RegisterReplicatedso migrated rows enter the oplog and replicate. - Tracking rows copy with
INSERT OR IGNORE(same TEXT PK ⇒ idempotent). - Legacy
site_eventsrows get deterministic ids —"mig-{NodeName}-{legacyId}"— NOT fresh GUIDs, so a crashed-then-rerun migration cannot duplicate events (INSERT OR IGNOREon the deterministic key). NodeName fromScadaBridge:Node:NodeName. - The whole copy runs in one
ILocalDb.BeginTransactionAsynctransaction; the legacy file is renamed to<name>.migratedonly 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: none
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Program.cssite 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 9
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.Host/LocalDbSyncAuthInterceptor.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Program.cs(siteAddGrpcgainsoptions.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 LocalDb:Replication:ApiKey via IOptions<ReplicationOptions> (same key
// 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 8
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs(health wiring — follow theAddSiteEventLogHealthMetricsBridgeprecedent at :76) - Test: extend
SiteLocalDbWiringTests
Bridge ISyncStatus (Connected, OplogBacklog — nullable, 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: none
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:
- Tracking row written on A → readable on B ≤ a few flush intervals.
- Same op updated on both nodes → both converge to one winner (LWW).
- Events logged on both nodes → union visible on both (GUID ids, no collisions).
- 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: none
Files:
- Modify:
docker/site-a-node-a/appsettings.Site.json— add"Replication": { "PeerAddress": "http://<site-a-node-b container name>:8083", "ApiKey": "<dev key>" }underLocalDb(take exact container hostname fromdocker/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):
bash docker/deploy.sh— rebuild + redeploy the 8-node cluster.- Generate tracking + event activity (existing rig flows / CLI).
- On both site-a nodes:
sqlite3 /app/data/site-localdb.db "SELECT count(*) FROM site_events"etc. — counts converge; spot-check row equality. bash docker/failover-drill.sh— after takeover, the new active node serves complete tracking/event history from before the flip.- Check
/metricson 8084 forlocaldb_*instruments; check the dead-letter table is empty. - 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: none
- ScadaBridge
CLAUDE.md: consolidated-DB note, config keys, replication flag posture, deleted config keys (old tracking/eventlog paths) marked migration-only. - scadaproj
CLAUDE.mdLocalDb component row: "no app adoption yet" → ScadaBridge Phase 1 adopted (state exactly what is and is not live, per the component-status-honesty convention). SiteEventLoggerXML 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 testoffline-green. - Verify each
ILocalDb.CreateConnection()assumption (open vs closed, UDF present) against the lib source at~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/on first use — the plan states behavior from its README + interface docs. - If any task needs files beyond its
Files:block, that is a plan defect — surface it rather than silently expanding scope.