35 KiB
OtOpcUa LocalDb Adoption — Phase 1 Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Execution model: This plan is optimized for Claude Opus agents (
claude --model opus). Dispatch implementer subagents on Opus for every task classifiedhigh-risk;small/trivialtasks may use Sonnet. Work on branchfeat/localdb-phase1in this repo (~/Desktop/OtOpcUa, remotelmxopcua). Do NOT merge tomasteras part of this plan — stop at the DoD task and report.Design authority:
~/Desktop/scadaproj/docs/plans/2026-07-20-otopcua-localdb-design.md. Read it before Task 0. The reference adoption is ScadaBridge (merged PR #23) — when in doubt, mirror~/Desktop/ScadaBridge(files named per task below).
Goal: Give every driver-role OtOpcUa node a consolidated ZB.MOM.WW.LocalDb database that caches
the deployed-configuration artifact (chunked), boots from that cache when central SQL Server is
unreachable, and optionally replicates it to the node's redundant pair peer over the library's gRPC
sync (default-OFF, fail-closed bearer auth).
Architecture: AddZbLocalDb/AddZbLocalDbReplication wired in a new LocalDbRegistration
(mirroring SecretsRegistration), DDL + RegisterReplicated in LocalDbSetup.OnReady, a dedicated
Kestrel h2c listener for MapZbLocalDbSync gated on LocalDb:SyncListenPort, a consumer-supplied
fail-closed bearer interceptor, and an IDeploymentArtifactCache seam written by DriverHostActor
after each successful apply and read as a boot fallback. The dormant LiteDB LocalCache subsystem is
deleted as superseded.
Tech stack: .NET 10, ZB.MOM.WW.LocalDb/.Replication/.Contracts 0.1.1 (Gitea feed),
Grpc.AspNetCore 2.76.0, Akka.NET (existing), xunit.v3 (Host.IntegrationTests) + xunit2 TestKit
(actor tests).
Hard rules (from the ScadaBridge adoption — violating any of these is a defect):
- Order inside
OnReadyis load-bearing: DDL →RegisterReplicated→ writes. Rows written before registration are never captured or snapshotted — silently, forever. - No autoincrement PKs, no BLOB columns in replicated tables.
- The library's passive sync endpoint verifies no ApiKey — the host interceptor is the only auth, and it must be fail-closed (no key configured ⇒ deny everything).
ILocalDb.CreateConnection()returns an already-open, pragma-configured connection with thezb_hlc_next()UDF. Never callOpen()on it. There is no in-memory mode — tests use temp files +SqliteConnection.ClearAllPools()before delete.Grpc.Core.Testingdoes not exist on grpc-dotnet — hand-roll fakeServerCallContexts.- Explicit Kestrel
Listen*calls make Kestrel ignoreASPNETCORE_URLS— when adding the sync listener you must re-bind the existing HTTP port in the same block, and verify it on the rig. - Never run host
sqlite3against a live bind-mounted WAL DB (copy thedb/-wal/-shmtriplet withcpinstead).aspnet:10.0containers have nocurl— use acurlimages/curlsidecar with--network container:<name>.
Task 0: Preflight + recon (produces docs/plans/2026-07-20-localdb-phase1-recon.md)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (everything downstream consumes its findings)
Files:
- Create:
docs/plans/2026-07-20-localdb-phase1-recon.md - Read-only:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs,src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs,src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs,src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs,Directory.Packages.props,docker-dev/docker-compose.yml, the observability registration (AddOtOpcUaObservabilityimplementation),src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/*
Step 1: Verify the feed packages restore.
Run:
cd ~/Desktop/OtOpcUa && dotnet package search ZB.MOM.WW.LocalDb --source dohertj2-gitea --format json | head -50
Expected: ZB.MOM.WW.LocalDb, .Replication, .Contracts at 0.1.1. If absent → STOP, report.
Step 2: Map and record, with file:line citations, each of:
- Deploy fetch path: where
DriverHostActorloadsDeployment.ArtifactBlob(the_dbFactory.CreateDbContext()sites, ~L1472/1543), the artifact's runtime type (DeploymentArtifact— how it's deserialized from the blob), the type/format ofDeploymentIdandRevisionHash, and where a successful apply completes (the point after which the cache write belongs). - Cold-boot path: what the actor does at startup before any
DispatchDeploymentarrives — does it query central SQL for the current deployment? Where does the failure path land (theStalestate,DbHealthProbeActorinteraction, L1345 dispatch-ignore)? Identify the exact seam where "central fetch failed at boot" is known — that is where the cache fallback goes. - Cluster identity: how a driver node knows its
ClusterId(config key /ClusterNoderow /IClusterRoleInfo) — the cache is keyed by it. - DriverHostActor construction: how its dependencies are injected
(
WithOtOpcUaRuntimeActors,Propswiring) soIDeploymentArtifactCachecan be threaded in. ⚠Props.Createbuilds an expression tree — adding a parameter silently rebinds out-of-position named args at call sites; list every construction site. - Telemetry allowlist: does
AddOtOpcUaObservabilityrestrict meters (ZbTelemetryOptions.Metersor equivalent)? If yes, record where the list lives. - Kestrel/URLs: confirm the Host binds via
ASPNETCORE_URLS=http://+:9000with noConfigureKestrelcalls; record any existingbuilder.WebHostusage. - LiteDB LocalCache: confirm (grep) that nothing outside
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/and its tests referencesILocalConfigCache/GenerationSealedCache/ResilientConfigReader/LiteDB. - docker-dev volumes: whether nodes have a writable data volume; record what
LocalDb:Pathshould be per node and how env vars are passed (Cluster__*style double-underscore).
Step 3: STOP conditions — halt the plan and report instead of improvising if:
- the artifact apply path cannot be given a post-apply hook without restructuring the actor;
DeploymentId/cluster identity are not stable strings/GUIDs;- LiteDB LocalCache turns out to be referenced by live code.
Step 4: Commit.
git add docs/plans/2026-07-20-localdb-phase1-recon.md
git commit -m "docs(localdb): phase-1 recon findings"
Task 1: Package references and pins
Classification: small Estimated implement time: ~3 min Parallelizable with: none (all code tasks build on it)
Files:
- Modify:
Directory.Packages.props - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj
Step 1: Add to Directory.Packages.props (versions exact):
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.1" />
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.1" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
Check the existing Grpc.Net.Client/Grpc.Core.Api are already ≥ 2.76.0 and
Google.Protobuf ≥ 3.34.1 (the LocalDb.Replication nuspec floors); raise if not. Confirm a
SQLitePCLRaw pin ≥ 2.1.12 exists for the transitive Microsoft.Data.Sqlite chain (the family
advisory GHSA-2m69-gcr7-jv3q); Core.AlarmHistorian already pins bundle_e_sqlite3 2.1.12 — add
SQLitePCLRaw.lib.e_sqlite3 2.1.12 as an explicit reference in the Host if the audit flags it.
Step 2: Host csproj: ZB.MOM.WW.LocalDb, ZB.MOM.WW.LocalDb.Replication, Grpc.AspNetCore.
Runtime csproj: ZB.MOM.WW.LocalDb only (it needs ILocalDb, not the sync engine).
Step 3: dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 warnings/errors (repo builds warnings-as-errors).
Step 4: Commit. git commit -m "build(localdb): reference ZB.MOM.WW.LocalDb 0.1.1 + Grpc.AspNetCore"
Task 2: Schema + LocalDbSetup.OnReady + registration tests
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/DeploymentCacheSchema.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/LocalDbSetupTests.cs(create the test project reference layout to match neighboring Host unit tests; if Host has no unit-test project, place in the closest existing one and note the deviation)
Step 1: Write the failing tests first — using a real temp-file ILocalDb (build it via
new ServiceCollection().AddZbLocalDb(config, LocalDbSetup.OnReady) with LocalDb:Path pointed at
a temp file; dispose + SqliteConnection.ClearAllPools() in cleanup):
OnReady_RegistersExactlyTheTwoDeploymentTables—db.ReplicatedTables.Keysordinal-sorted equals["deployment_artifacts", "deployment_pointer"](assert BOTH directions: no fewer, no more).DeploymentArtifacts_PkIsDeploymentIdPlusChunkIndexandDeploymentPointer_PkIsClusterId(pinReplicatedTable.PkColumns).RowsWrittenAfterOnReady_EnterTheOplog— insert a row, assertSELECT COUNT(*) FROM __localdb_oplog≥ 1 (pins the DDL→register ordering; this is the assertion that catches a silently-broken CDC).
Step 2: Run tests, watch them fail (types don't exist yet).
Step 3: Implement. DeploymentCacheSchema depends only on Microsoft.Data.Sqlite (the
ScadaBridge *Schema.Apply pattern — self-sufficient for direct construction in tests):
namespace ZB.MOM.WW.OtOpcUa.Runtime.Deployment;
public static class DeploymentCacheSchema
{
public static void Apply(SqliteConnection connection)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS deployment_artifacts (
deployment_id TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
cluster_id TEXT NOT NULL,
revision_hash TEXT NOT NULL,
chunk_count INTEGER NOT NULL,
chunk_base64 TEXT NOT NULL,
cached_at_utc TEXT NOT NULL,
PRIMARY KEY (deployment_id, chunk_index)
);
CREATE TABLE IF NOT EXISTS deployment_pointer (
cluster_id TEXT NOT NULL PRIMARY KEY,
deployment_id TEXT NOT NULL,
revision_hash TEXT NOT NULL,
artifact_sha256 TEXT NOT NULL,
applied_at_utc TEXT NOT NULL
);
""";
cmd.ExecuteNonQuery();
}
}
LocalDbSetup (Host):
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
internal static class LocalDbSetup
{
// ORDER IS LOAD-BEARING: DDL, then RegisterReplicated, then (nothing else in Phase 1).
// Rows written before RegisterReplicated are invisible to the peer forever, silently.
public static void OnReady(ILocalDb db)
{
using var connection = db.CreateConnection(); // already open — do NOT call Open()
DeploymentCacheSchema.Apply(connection);
db.RegisterReplicated("deployment_artifacts");
db.RegisterReplicated("deployment_pointer");
}
}
Step 4: dotnet test --filter "FullyQualifiedName~LocalDbSetupTests" → PASS.
Step 5: Commit. git commit -m "feat(localdb): deployment-cache schema + OnReady registration"
Task 3: Fail-closed sync auth interceptor + tests
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 2
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSyncAuthInterceptor.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/LocalDbSyncAuthInterceptorTests.cs
Port ScadaBridge's src/ZB.MOM.WW.ScadaBridge.Host/LocalDbSyncAuthInterceptor.cs (read it first;
keep behavior identical, adjust namespace only). Semantics to preserve exactly:
ServicePrefix = "/localdb_sync.v1.LocalDbSync/"; any other method path passes through untouched.- Expected token from
IOptions<ReplicationOptions>.Value.ApiKey. - Fail-closed: null/empty configured key ⇒
RpcException(new Status(StatusCode.PermissionDenied, ...))for every sync call. Missing/mismatched bearer ⇒ same. CryptographicOperations.FixedTimeEqualsover UTF-8 bytes; headerauthorization, case-insensitive,"Bearer "prefix. Override all four server handler kinds.
Tests (hand-rolled fake ServerCallContext — Grpc.Core.Testing does not exist on grpc-dotnet;
copy ScadaBridge's LocalDbSyncAuthInterceptorTests.cs fake): non-sync method passes with no key;
sync + no configured key → PermissionDenied; wrong bearer → PermissionDenied; correct bearer → passes.
Write tests first (fail), implement, dotnet test --filter "FullyQualifiedName~LocalDbSyncAuthInterceptor",
commit feat(localdb): fail-closed bearer interceptor for the sync endpoint.
Task 4: LocalDbRegistration + Program.cs DI wiring + config defaults
Classification: high-risk (touches Program.cs / role gating) Estimated implement time: ~5 min Parallelizable with: none (Task 5 edits the same file)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs(inside thehasDriverbranch) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
Step 1: LocalDbRegistration, mirroring SecretsRegistration's shape:
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
internal static class LocalDbRegistration
{
/// Driver-role nodes only. Storage is unconditional; replication stays inert until
/// LocalDb:Replication:PeerAddress (initiator) / LocalDb:SyncListenPort (listener) are set.
public static IServiceCollection AddOtOpcUaLocalDb(
this IServiceCollection services, IConfiguration configuration)
{
services.AddZbLocalDb(configuration, LocalDbSetup.OnReady);
services.AddZbLocalDbReplication(configuration);
return services;
}
public static int SyncListenPort(IConfiguration configuration) =>
configuration.GetValue<int>("LocalDb:SyncListenPort");
}
Step 2: Program.cs, in the hasDriver block (near AddAlarmHistorian):
builder.Services.AddOtOpcUaLocalDb(builder.Configuration); and
builder.Services.AddGrpc(o => o.Interceptors.Add<LocalDbSyncAuthInterceptor>());
(AddGrpc only under hasDriver — admin-only nodes expose no sync surface).
Step 3: appsettings.json:
"LocalDb": {
"Path": "./data/otopcua-localdb.db",
"SyncListenPort": 0,
"Replication": {}
}
LocalDb:Path is ValidateOnStart-required once AddZbLocalDb runs — since registration is
driver-gated, admin-only configs need nothing. Check the role-overlay files
(appsettings.driver.json / appsettings.admin-driver.json) and any deploy templates for
conflicting LocalDb keys.
Step 4: Build + full Host unit tests. Verify an admin-only graph doesn't require the key: this
is pinned properly in Task 10's integration tests, but do a quick
OTOPCUA_ROLES=admin dotnet run smoke only if cheap; otherwise rely on Task 10.
Step 5: Commit. feat(localdb): wire AddOtOpcUaLocalDb into the driver role
Task 5: Dedicated h2c sync listener + endpoint mapping (THE Kestrel task)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (Program.cs)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
Why high-risk: the Host today binds exclusively via ASPNETCORE_URLS=http://+:9000. Any
explicit ConfigureKestrel(... Listen*) makes Kestrel ignore URLs entirely (it logs
"Overriding address(es)"), which would silently kill the AdminUI/deploy API behind Traefik.
Step 1: Add, before builder.Build():
var syncPort = LocalDbRegistration.SyncListenPort(builder.Configuration);
if (hasDriver && syncPort > 0)
{
// Explicit Listen* replaces ASPNETCORE_URLS wholesale, so re-bind the primary HTTP
// endpoint here too. Parse it from the configured URLs rather than hard-coding 9000.
var urls = builder.Configuration["ASPNETCORE_URLS"] ?? builder.Configuration["urls"] ?? "http://+:9000";
var httpPort = new Uri(urls.Split(';')[0].Replace("+", "localhost").Replace("*", "localhost")).Port;
builder.WebHost.ConfigureKestrel(k =>
{
k.ListenAnyIP(httpPort); // HTTP/1.1 (existing surface)
k.ListenAnyIP(syncPort, o => o.Protocols = HttpProtocols.Http2); // h2c, prior-knowledge gRPC
});
}
When syncPort == 0 (the default) nothing changes — URLs binding stays untouched. Cleartext
Http1AndHttp2 cannot serve prior-knowledge h2c, hence the dedicated Http2-only listener.
Step 2: After the app pipeline's other Map* calls, driver-gated:
if (hasDriver && syncPort > 0)
{
app.MapZbLocalDbSync();
}
(Mapping is harmless when unauthenticated — the interceptor fail-closes — but gating on the port keeps admin-only and default-OFF graphs entirely free of the endpoint.)
Step 3: Verify both surfaces.
dotnet build ZB.MOM.WW.OtOpcUa.slnx
Then a local smoke with the port on:
ASPNETCORE_URLS=http://+:9000 OTOPCUA_ROLES=driver LocalDb__SyncListenPort=9001 dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Host ...
— expect startup logs to show BOTH :9000 and :9001 bound, and curl -s localhost:9000/healthz
(or the mapped health route) still answering. If the app needs SQL to boot, defer the smoke to the
Task 12 rig check but say so explicitly in the task notes.
Step 4: Commit. feat(localdb): dedicated h2c listener + MapZbLocalDbSync gated on LocalDb:SyncListenPort
Task 6: IDeploymentArtifactCache + chunked LocalDb implementation + tests
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3, Task 5
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/IDeploymentArtifactCache.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/LocalDbDeploymentArtifactCache.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs
Step 1: Failing tests (real temp-file ILocalDb with LocalDbSetup.OnReady applied):
- round-trip: store a 300 KiB random artifact →
GetCurrentAsyncreturns byte-identical payload (forces ≥ 3 chunks at 128 KiB); - retention: store 3 deployments for one cluster → only the newest 2 remain in
deployment_artifacts; pointer names the newest; - integrity: corrupt one chunk row via SQL →
GetCurrentAsyncreturns null (miss), never a truncated artifact; - missing pointer → null.
Step 2: Implement:
public interface IDeploymentArtifactCache
{
Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
byte[] artifact, CancellationToken ct = default);
Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default);
}
public sealed record CachedDeploymentArtifact(
string DeploymentId, string RevisionHash, byte[] Artifact, DateTimeOffset AppliedAtUtc);
LocalDbDeploymentArtifactCache(ILocalDb db):
StoreAsync: oneILocalDbTransaction— delete any existing chunks for thisdeployment_id(idempotent re-store), insert chunks (raw 128 * 1024 bytes per chunk,Convert.ToBase64String), upsert the pointer (INSERT ... ON CONFLICT(cluster_id) DO UPDATE), then prune: deletedeployment_artifactsrows whosedeployment_idis not among the newest 2cached_at_utcfor thiscluster_id. SHA-256 over the raw artifact into the pointer. ISO-8601 UTC timestamps.GetCurrentAsync: read pointer; read chunksORDER BY chunk_index; verify count ==chunk_countand SHA-256 matches; return null on any mismatch (log a warning naming which check failed).- Use
db.ExecuteAsync/QueryAsyncwith anonymous-object parameters (@Namemarkers). Remember aDictionary<string,string>parameter throws — use anonymous objects.
Step 3: run tests → PASS. Step 4: Commit.
feat(localdb): chunked deployment-artifact cache over ILocalDb
Task 7: Cache write path in DriverHostActor
Classification: high-risk (actor model, Props expression-tree trap)
Estimated implement time: ~5 min
Parallelizable with: none
Files: (exact edit sites come from the Task 0 recon doc — cite it in the commit)
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs - Modify: the actor-registration site (
WithOtOpcUaRuntimeActors/ DI extension) to provideIDeploymentArtifactCache - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.csorLocalDbRegistration— registerIDeploymentArtifactCache→LocalDbDeploymentArtifactCachesingleton (driver branch) - Test: the xunit2 TestKit project used for existing DriverHostActor tests (recon names it)
Steps:
- Failing test: after a successful artifact apply, the cache received
StoreAsyncwith the applied deployment's id/hash/bytes (inject a recording fakeIDeploymentArtifactCache). Also: a cache that throws onStoreAsyncdoes NOT fail the apply (apply result unchanged, error logged). - Thread the dependency through the actor's constructor. ⚠
Props.Createis an expression tree: after adding the parameter, re-check every construction site for positional/named-arg rebinding (the recon lists them) — do not rely on the compiler. - Invoke
StoreAsyncfire-and-forget (PipeTo-style or a guardedTask.Runper the actor's existing async conventions — match whatever pattern the actor already uses for side-effect IO) at the post-apply point identified in recon. Cluster id from the recon-identified source. - Run the actor test suite for this project. Commit:
feat(localdb): DriverHostActor stores applied artifacts in the pair-local cache
Task 8: Boot-from-cache read path + running-from-cache signal
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs - Test: same xunit2 TestKit project as Task 7
Steps:
- Failing tests:
- central fetch fails at startup AND cache has an artifact → the actor applies the cached artifact (assert via whatever observable the apply already exposes) and logs/flags running-from-cache;
- central fetch fails AND cache empty → today's behavior exactly (Stale, no apply);
- central fetch succeeds → cache is NOT consulted (fresh config wins; assert the fake
cache's
GetCurrentAsyncwas never called on the happy path).
- Implement at the recon-identified boot-failure seam. The signal: a log warning at minimum plus,
if the actor already publishes health/status (recon says how), a
RunningFromCachemarker on it. Do NOT touchDispatchDeploymenthandling — a new deployment still requires central. - Run tests; commit
feat(localdb): boot from the pair-local artifact cache when central SQL is unreachable.
Task 9: Delete the dormant LiteDB LocalCache subsystem
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 6, Task 10, Task 11
Files:
- Delete:
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/(entire directory:ILocalConfigCache,LiteDbConfigCache,GenerationSealedCache,ResilientConfigReader,GenerationSnapshot,StaleConfigFlag, exceptions) - Delete:
tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationSealedCacheTests.cs,ResilientConfigReaderTests.cs - Modify:
Directory.Packages.props+ the Configuration csproj — remove theLiteDBpackage if nothing else references it (grep first)
Steps: Confirm the Task 0 recon's "nothing references it" finding still holds
(grep -rn "ILocalConfigCache\|GenerationSealedCache\|ResilientConfigReader\|LiteDB" src/ tests/),
delete, build the full solution, run the Configuration test project. DoD phrasing: no references
from code — leave any explanatory prose/comments that point readers at LocalDb instead. Add one
line to the recon doc noting LiteDB's removal. Commit:
refactor(localdb): delete the dormant LiteDB LocalCache (superseded by ZB.MOM.WW.LocalDb)
Task 10: Health check + telemetry meter allowlist
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 9, Task 11
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Health/LocalDbReplicationHealthCheck.cs(or the directoryAddOtOpcUaHealthuses — recon names it) - Modify: the
AddOtOpcUaHealthregistration (driver branch) - Modify: the observability meter list IF Task 0 found an allowlist
Steps:
- Failing unit tests: replication unconfigured (no peer, no listener) →
Healthy(default-OFF must not degrade a plain node); peer configured +ISyncStatus.Connected == false→Degraded; connected + backlognull→Degraded(unknown backlog is not healthy); connected + backlog small →Healthy. - Implement against
ISyncStatus+IOptions<ReplicationOptions>(peer-configured = non-emptyPeerAddressORSyncListenPort > 0— pass the latter in via options/config). - Meter allowlist: if
AddOtOpcUaObservabilityrestricts meters, add"ZB.MOM.WW.LocalDb.Replication"(useLocalDbMetrics.MeterName) — this is a silent allowlist; the ScadaBridge live gate is the proof it bites. If there is no allowlist, record that in the recon doc and skip. - Commit
feat(localdb): replication health check + meter export.
Task 11: DI-pin integration tests over the real Program.cs
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 9, Task 10
Files:
- Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs(xunit.v3,WebApplicationFactory<Program>— the project already builds the real Program.cs; setOTOPCUA_ROLESper test case the way neighboring tests do)
Pins (each its own test):
- Driver graph:
ILocalDbresolves, is a singleton,ReplicatedTablesordinal-sorted ==["deployment_artifacts", "deployment_pointer"]— exact set, both directions load-bearing. - Driver graph:
IDeploymentArtifactCacheresolves toLocalDbDeploymentArtifactCache. - Default-OFF pin:
ISyncStatusresolves withConnected == false,PeerNodeId == null. - Admin-only graph:
ILocalDbis NOT registered (GetService<ILocalDb>()is null) and boot does not demandLocalDb:Path. - Health: the LocalDb health check is registered in the driver graph.
Point LocalDb:Path at a per-test temp file via the factory's config overrides;
ClearAllPools + delete in cleanup. These tests exist because DI extensions without a
container-built test have shipped inert three times in this family (Secrets 0.2.0/0.2.2,
ScadaBridge#22). Commit test(localdb): DI pins over the real host graph.
Task 12: Two-node convergence harness + tests
Classification: high-risk Estimated implement time: ~5 min (harness) + ~4 min (scenarios) — split the commit if needed Parallelizable with: none (depends on Tasks 2, 3, 5, 6)
Files:
- Create:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairHarness.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDb/LocalDbPairConvergenceTests.cs
Harness: copy the pattern from
~/Desktop/ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/LocalDbSitePairHarness.cs and
the library's ConvergenceFixture (~/Desktop/scadaproj/ZB.MOM.WW.LocalDb/tests/.../Convergence/ConvergenceFixture.cs):
- Two full stacks over a real loopback Kestrel h2c socket (port 0), through the real
LocalDbSyncAuthInterceptorwith a shared test key; node A initiator, node B passive. - Both initialized via the production
LocalDbSetup.OnReady— a hand-written schema proves only that the test agrees with itself. - DBs owned by the harness, registered as pre-constructed singletons (so MS.DI won't dispose them
on host teardown);
KillTransportAsync/RestartTransportAsync; tightFlushInterval(50 ms), bounded backoff (2 s); temp files,ClearAllPoolscleanup; a collection definition to serialize these tests.
Scenarios:
ArtifactStoredOnA_ConvergesToB_ByteIdentical— store a multi-chunk artifact viaLocalDbDeploymentArtifactCacheon A; B's cache returns byte-identical bytes; both nodes'__localdb_row_versionrows for the pointer carry the same HLC and origin node id (B holds A's row, not a re-derived one).RetentionPruneOnA_TombstonesReachB— third deployment on A prunes the first; B's chunk rows for the pruned deployment disappear and__localdb_row_version WHERE is_tombstone=1rows exist on B.WritesWhileTransportDown_SurviveRejoin— kill transport, store on A, restart, converge.WrongApiKey_NeverConverges— harness with mismatched keys: assert no convergence after a bounded wait AND (positive control) that the same scenario with matching keys converges — an absence assertion without a positive control passed vacuously in ScadaBridge.
DoD within this task: temporarily comment out the two RegisterReplicated calls and confirm
scenarios 1–3 go red (run locally, do not commit the red state); restore. Record the red/green
evidence in the task notes. Commit test(localdb): 2-node convergence harness + scenarios.
Task 13: docker-dev rig configuration
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 14
Files:
- Modify:
docker-dev/docker-compose.yml
Steps (adjust to the recon's findings on volumes/env style):
- Every driver node: a named volume (or existing data mount) backing
/app/data, andLocalDb__Path=/app/data/otopcua-localdb.db. - site-a pair only (mirror the ScadaBridge default-OFF posture — site-b stays unreplicated as
the pin):
- site-a-1:
LocalDb__SyncListenPort=9001,LocalDb__Replication__PeerAddress=http://site-a-2:9001,LocalDb__Replication__ApiKey=dev-site-a-localdb-sync-key,LocalDb__Replication__MaxBatchSize=16 - site-a-2:
LocalDb__SyncListenPort=9001, sameApiKey, sameMaxBatchSize, no PeerAddress (passive; the stream is bidirectional). The key must be byte-identical on both — the interceptor fail-closes on any mismatch and the pair silently stops converging.
- site-a-1:
MaxBatchSize=16because batching is row-count-only against gRPC's 4 MB cap and artifact chunk rows are ≈ 171 KB (16 × 171 KB ≈ 2.7 MB worst case).docker compose configto validate; do NOT bring the rig up in this task (the live gate task owns rig runs).- Commit
chore(localdb): rig config — consolidated path everywhere, replication on the site-a pair.
Task 14: Documentation
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 13
Files:
- Create:
docs/operations/2026-07-20-localdb-pair-replication.md(runbook: enable/disable, ApiKey handling via${secret:}, stop/start-together rule, tombstone-retention resurrection window, MaxBatchSize row-count-vs-4MB, never host-sqlite3a live WAL DB / cp-triplet recipe) - Modify:
docs/Redundancy.md(a short "pair-local config cache" section: what replicates, what boot-from-cache does and does not cover) - Modify:
CLAUDE.md(this repo): LocalDb adoption row/paragraph — state Phase 1 scope, default-OFF, rig-only enablement - Modify:
docs/Configuration.mdif it documents config keys (add theLocalDbsection)
Commit docs(localdb): phase-1 runbook + redundancy/config docs.
Task 15: DoD sweep (offline)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (final offline task)
Steps:
dotnet build ZB.MOM.WW.OtOpcUa.slnx→ 0 warnings (warnings-as-errors).dotnet test ZB.MOM.WW.OtOpcUa.slnx→ full suite green; record counts. Baseline any pre-existing failures BEFORE this branch (git stash→ run → unstash) rather than assuming; report deltas only.- Greps (phrase as "no references from code"; explanatory comments may remain):
grep -rn "LiteDB\|ILocalConfigCache\|GenerationSealedCache" src/ tests/→ no code references. - Confirm the positive-control evidence from Task 12 is recorded.
- Update
docs/plans/2026-07-20-localdb-adoption-phase1.md.tasks.jsonstatuses. - Commit
chore(localdb): phase-1 DoD sweepand STOP. Report: branch name, commit list, test counts, and that the live gate (Task 16) needs the rig + operator go-ahead.
Task 16: Live gate on the docker-dev rig (run only with explicit user go-ahead)
Classification: high-risk (rig) Estimated implement time: ~30 min wall-clock (mostly waiting) Parallelizable with: none
Preamble: All DB inspection via cp of the db/-wal/-shm triplet out of the container
(docker cp) and querying the copy — never host sqlite3 on the live file (it poisons the WAL
across virtiofs; root cause of the 2026-07-20 ScadaBridge incident). Metrics via
docker run --rm --network container:<node> curlimages/curl:latest -s localhost:<port>/metrics.
If an anomaly appears within seconds of one of your own measurements, suspect the measurement
first — restart both nodes and re-run untouched before blaming the library.
Checks (all must PASS; record evidence in docs/plans/2026-07-20-localdb-phase1-live-gate.md):
- Rig up, site-a pair healthy,
/metricson both showslocaldb_series (proves the meter allowlist work). - Deploy a config through the normal deploy API → both site-a nodes'
deployment_pointer+deployment_artifactsrows are byte-identical with identical HLC + origin node id (one node's row replicated, not two independent derivations — both nodes also locally store after apply, so expect LWW-converged identical content either way; assert convergence, and note which origin won). - Boot-from-cache: stop the SQL container; restart site-a-2; it comes up serving the cached config with the running-from-cache signal in its log; start SQL again; node recovers fresh behavior.
- Replicated-cache payoff: wipe site-a-2's local DB file (container stopped), restart it with SQL up, let replication repopulate the cache from site-a-1, then repeat check 3's SQL-down restart — it boots from a cache it never wrote itself.
- Transport kill (pause site-a-2 container): store/deploy on a-1, oplog depth rises; unpause; drains to 0; converged.
- Retention: deploy a 3rd config; pruned deployment's chunks gone on BOTH nodes with tombstone rows present on both.
- Both-nodes-together restart: clean rejoin, identical counts, zero
disk I/O error/SQLITE_IOERR/corruptin either log. - site-b pair (default-OFF pin): LocalDb file exists and works locally,
ISyncStatushealth Healthy, no sync connections, no listener on 9001.
Record PASS/FAIL per check with the actual evidence (row counts, md5s, log lines). Commit the gate doc. Do not merge — report.
Task persistence
Tasks file: docs/plans/2026-07-20-localdb-adoption-phase1.md.tasks.json (same directory).
Update statuses as tasks complete; any deviation from this plan gets a deviation note on the task
(the ScadaBridge tasks.json convention — the record of why is what makes the plan auditable).