From ed9644fa3dcb0128e35409b953a1ed3bbf16584b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 01:57:53 -0400 Subject: [PATCH] docs(localdb): Phase 1 implementation plan for LocalDb adoption Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts --- .../2026-07-19-localdb-adoption-phase1.md | 373 ++++++++++++++++++ ...7-19-localdb-adoption-phase1.md.tasks.json | 20 + 2 files changed, 393 insertions(+) create mode 100644 docs/plans/2026-07-19-localdb-adoption-phase1.md create mode 100644 docs/plans/2026-07-19-localdb-adoption-phase1.md.tasks.json diff --git a/docs/plans/2026-07-19-localdb-adoption-phase1.md b/docs/plans/2026-07-19-localdb-adoption-phase1.md new file mode 100644 index 00000000..5cd5f036 --- /dev/null +++ b/docs/plans/2026-07-19-localdb-adoption-phase1.md @@ -0,0 +1,373 @@ +# 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 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: + +```xml + + + +``` + +**Step 2:** Add feed mapping patterns to the `dohertj2-gitea` packageSource block in `NuGet.config`: + +```xml + + +``` + +**Step 3:** Add `` 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**: + +```sql +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`:** + +```csharp +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; + +/// +/// 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. +/// +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): + +```csharp +// 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 `AddSiteRuntime` registers the store (follow `services.AddSiteRuntime` from `SiteServiceRegistration.cs:51` into 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:SiteEventLog` DatabasePath); missing file ⇒ no-op. +- 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 `.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:** none + +**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()` (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` (site `AddGrpc` gains `options.Interceptors.Add()`) +- 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: + +```csharp +/// +/// 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. +/// +public sealed class LocalDbSyncAuthInterceptor : Interceptor +{ + private const string ServicePrefix = "/localdb_sync.v1.LocalDbSync/"; + // read LocalDb:Replication:ApiKey via IOptions (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 the `AddSiteEventLogHealthMetricsBridge` precedent 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: +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:** none + +**Files:** +- Modify: `docker/site-a-node-a/appsettings.Site.json` — add `"Replication": { "PeerAddress": "http://:8083", "ApiKey": "" }` under `LocalDb` (take exact container hostname from `docker/docker-compose.yml`) +- Modify: `docker/site-a-node-b/appsettings.Site.json` — `"Replication": { "ApiKey": "" }` (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:** none + +- 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. +- 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. diff --git a/docs/plans/2026-07-19-localdb-adoption-phase1.md.tasks.json b/docs/plans/2026-07-19-localdb-adoption-phase1.md.tasks.json new file mode 100644 index 00000000..3f8fce1a --- /dev/null +++ b/docs/plans/2026-07-19-localdb-adoption-phase1.md.tasks.json @@ -0,0 +1,20 @@ +{ + "planPath": "docs/plans/2026-07-19-localdb-adoption-phase1.md", + "tasks": [ + {"id": 1, "subject": "Task 1: Add LocalDb packages to the build", "status": "pending"}, + {"id": 2, "subject": "Task 2: Shared schema helpers (tracking + events, GUID PK)", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 3: Register LocalDb in the site composition root", "status": "pending", "blockedBy": [2]}, + {"id": 4, "subject": "Task 4: Rewire OperationTrackingStore onto ILocalDb connections", "status": "pending", "blockedBy": [3]}, + {"id": 5, "subject": "Task 5: Rewire SiteEventLogger + EventLogQueryService (GUID ids)", "status": "pending", "blockedBy": [3]}, + {"id": 6, "subject": "Task 6: One-time legacy data migrator", "status": "pending", "blockedBy": [4, 5]}, + {"id": 7, "subject": "Task 7: Replication registration + sync endpoint (default OFF)", "status": "pending", "blockedBy": [6]}, + {"id": 8, "subject": "Task 8: Bearer auth interceptor for the sync endpoint", "status": "pending", "blockedBy": [7]}, + {"id": 9, "subject": "Task 9: Surface ISyncStatus as site health signal", "status": "pending", "blockedBy": [7]}, + {"id": 10, "subject": "Task 10: Two-node in-process convergence test", "status": "pending", "blockedBy": [8]}, + {"id": 11, "subject": "Task 11: Docker rig enablement (site-a pair)", "status": "pending", "blockedBy": [10]}, + {"id": 12, "subject": "Task 12: Live gate on the docker rig", "status": "pending", "blockedBy": [11]}, + {"id": 13, "subject": "Task 13: Documentation truth pass", "status": "pending", "blockedBy": [12]}, + {"id": 14, "subject": "Task 14: Phase 2 gate - plan the bespoke-replicator migration", "status": "pending", "blockedBy": [12]} + ], + "lastUpdated": "2026-07-19" +}