# ZB.MOM.WW.LocalDb Shared embedded SQLite cache + 2-node LWW sync library for the **ZB.MOM.WW SCADA family** (3 packages; consumed by ScadaBridge and OtOpcUa). Build/test/pack from this directory: `dotnet build ZB.MOM.WW.LocalDb.slnx`, `dotnet test ZB.MOM.WW.LocalDb.slnx` (fully offline), `dotnet pack ZB.MOM.WW.LocalDb.slnx -c Release -o artifacts`. Design: `../docs/plans/2026-07-17-localdb-design.md`; adoption designs `../docs/plans/2026-07-19-scadabridge-localdb-design.md` + `../docs/plans/2026-07-20-otopcua-localdb-design.md`. ## Index history (migrated from the scadaproj root CLAUDE.md) > Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.** ### Component-table row as it read in the index | Component | Status | Goal | Design | Implementation | |---|---|---|---|---| | LocalDb (embedded cache + 2-node sync) | **Adopted by ScadaBridge — Phases 1 AND 2 COMPLETE and MERGED** to `main` (2026-07-20, PR #23, merge `28ca04d7`, pushed to origin; both `feat/localdb-phase*` branches fully contained and kept only as history). Libs **`0.3.4`** on the Gitea feed (3 pkgs; `0.1.0`–`0.3.3` also present). **`0.3.4` (2026-08-20, scadaproj `75c1897` — ScadaBridge register row 106; sender-side, wire-compatible, no schema change):** `SnapshotStreamer` stamps `as_of_seq = max(MAX(seq), last_acked_seq)` instead of bare `MAX(seq)`, which read **0** once ack-pruning had emptied a converged sender's oplog — so a rebuilt peer (watermark 0) was healed by the snapshot but never lifted by it, and the `0.1.2` empty-oplog horizon rule re-streamed the whole database on EVERY reconnect until the sender wrote again (ScadaBridge's site-a-a: 127 k rows per restart, observed on two consecutive restarts). Pinned by `WipedPeer_SnapshotLiftsItsWatermark_…`; 216/216. **ScadaBridge pins `0.3.4`** (2026-08-20; live: first restart sent one last snapshot `as_of 149`, second restart sent none); OtOpcUa remains `0.2.1` — note OtOpcUa's rebuilt-node path has the same loop until it re-pins. **`0.3.3` (2026-08-20, scadaproj `122940c` — ScadaBridge arch-review P4-7; wire-compatible, no proto/schema change)** closes two latent `LwwApplier` hazards: (1) **`RegisterReplicated` refuses a table that is the parent of a row-mutating `ON DELETE` FK** (`CASCADE`/`SET NULL`/`SET DEFAULT`; `RESTRICT`/`NO ACTION` allowed) via `LocalDbRegistrationException` naming `child.column ON DELETE ` — the applier's `INSERT OR REPLACE` runs with `foreign_keys=ON`, so the implicit delete fired child actions while capture was suppressed, a silent divergence for any future FK'd table (none of ScadaBridge's ten or OtOpcUa's four has an FK today; registration-time check only, since SQLite can't add an FK to an existing table); (2) the per-entry apply (delete-or-upsert + ledger write) is **atomic under a `SAVEPOINT`** — a fault between the two statements left a deleted row with a live ledger that `SnapshotStreamer` warns-and-skips forever; poison-catch semantics preserved. 213/213 tests (+9, failure injected with a `RAISE(ABORT)` trigger on the ledger, no production seam). **ScadaBridge pins `0.3.3`** (2026-08-20); OtOpcUa remains `0.2.1`. **`0.3.2` (2026-08-19, scadaproj `f62b017` — ScadaBridge arch-review P4-1 + P4-3; wire-compatible, no proto/schema change)**: (1) the sync pump **faults a live session when a cap-prune invalidates its position** (oplog min-seq horizon check before each read batch + on the batch head; new public `LocalDbOplogGapException` rides the 0.3.1 fault teardown) — previously a slow-but-alive peer silently streamed past the pruned gap and the pair diverged until the next reconnect; (2) **client h2 keepalive pings** via new validated `Replication:KeepAlivePingDelay`/`KeepAlivePingTimeout` (60s/20s, `Always` policy); (3) the server single-stream guard is **newest-wins with an awaited handover** (`TakeoverTimeout` 10s) — a fresh authenticated `Sync` displaces a dead incumbent instead of being refused `AlreadyExists` forever by a half-open link whose zombie side reports connected. 204/204 tests. **ScadaBridge pins `0.3.2`** (2026-08-19, adding Kestrel h2 keepalive on its site gRPC listener as the server half); OtOpcUa remains `0.2.1`. **`0.3.1` (2026-08-18, scadaproj `3e5a5fd`) flushes a faulting session's control lane before its writer is cancelled** — on a handshake-phase fault (i.e. a schema-mismatch rejection) `RunAsync`'s teardown cancelled the writer while the session's OWN Handshake could still be sitting in the lane (the enqueue→receive→validate→fault path has no yields while the writer's wakeup waits in the thread-pool queue; a pinning test lost the race 39/40), and mismatch detection is symmetric — the peer computes its fail-closed verdict FROM that Handshake — so the healthy peer misread a permanent schema mismatch as a generic disconnect and reconnect-looped at the fast backoff instead of the classified `FailedPrecondition`→LogError+max-backoff path. Teardown now completes both lanes so the writer drains what is already enqueued (2 s deferred cancel as the wedged-transport backstop); caller-initiated shutdown is not delayed, **no wire or config change** — an older peer simply receives the Handshake it was always owed. Closes ScadaBridge deferred-register row 41 (its flaky `Handshake_DigestMismatch_FailsClosed` reproduced 2/6 pre-fix under load, 6/6 green post-fix; the other named flake, `BatchBudgetTests.Pump_RowLargerThanTheBudget…`, was a harness defect — session faults hidden as opaque 15 s timeouts — fixed with a run-observing `NextAsync`). **ScadaBridge re-pinned through `0.3.1` to `0.3.2` (2026-08-19); OtOpcUa remains `0.2.1`.** **`0.3.0` (2026-08-15) makes the snapshot-resync flag PER-TABLE** (branch `localdb-per-table-snapshot`; the residual `0.2.1` explicitly deferred). On-disk bookkeeping schema goes to **v3** — a new `__localdb_snapshot_state` table, upgraded in place on open (the transition is a version stamp only: the shared DDL creates the table with `IF NOT EXISTS` on every open, and empty is the correct v3 state for a file that never baselined). **Wire-compatible with `0.1.x`/`0.2.x`, and `lib_schema_version` deliberately STAYS `1`** because `ValidateHandshake` compares it fail-closed for equality — bumping it would stop mixed pairs syncing outright, so the extension is negotiated by **capability**: `Handshake.supports_partial_snapshot` (new field 5) and `SnapshotBegin.tables` (new field 2, where **empty == all tables**, which is exactly what a pre-`0.3.0` sender emits and a pre-`0.3.0` receiver assumes). `RegisterReplicated(table, baselineExistingRows: true)` now flags **only that table**, so a snapshot streams only the flagged tables; the database-wide `__localdb_peer_state.needs_snapshot` survives unchanged for the cases that genuinely reach every table (oplog cap prune, impossible peer ack). **A peer that does not advertise the capability is served a FULL snapshot** — a scoped one would make it advance `last_applied_remote_seq` to the snapshot's as-of and silently discard every delta below it for the tables left out. For the same reason **a partial snapshot moves no watermark on either side**: the receiver keeps `last_applied_remote_seq` and the sender keeps `_sentThruSeq`, so the overlapping deltas are still pumped and merged by LWW. Crash safety: snapshot debt now clears on the receiver's new **`SnapshotAck`** (exchanged only between `0.3.0` peers) rather than on enqueue, so an ungraceful exit mid-snapshot re-streams the same scope on reconnect; against a pre-`0.3.0` peer the `0.2.x` clear-on-enqueue is kept, there being no ack to wait for. Composes with `0.2.1`'s bounded 64-message inbox and out-of-handshake snapshot (the ack rides the unbounded CONTROL lane, like `DeltaAck`). 199/199 tests, including a single-table-baseline scope test, a mixed-version interop test (old peer ⇒ full snapshot), an ack-gated-debt test and a v2→v3 upgrade test. **ScadaBridge pins `0.3.0`**; `Host/SiteLocalDbSetup.cs` needed no restructuring — it already calls `RegisterReplicated` once per table, which is what per-table flagging keys off, so adding an eleventh replicated table to an already-replicating site now streams that one table instead of all eleven. **`0.2.1` (2026-08-14) closes two adversarial-review findings against `0.2.0`** (same branch `localdb-perf-remediation`; wire-compatible, no proto/`lib_schema_version`/on-disk-schema change): **`DeregisterReplicated` now flushes the HLC's durable crash anchor inside its own transaction, before the prune** — clock recovery is `MAX(hlc_high_water, oplog HLCs, ledger HLCs, last_seen_hlc)` and `hlc_high_water` was written only by `Dispose`, so on a file whose tables had all been deregistered an ungraceful exit left the clock nothing to recover from and it could re-issue stamps below HLCs peers already held (LWW then discards them as older); and **the gRPC sync inbox is bounded at 64 messages with wait-mode backpressure** instead of unbounded, capping inbound memory at ~`64 × MaxBatchBytes` rather than "whatever the peer sends" — the bad case being a **mutual full snapshot** (the snapshot flag is per-DATABASE and both sides of a late-enabled pair baseline unconditionally), previously buffered whole in memory on both nodes at once. Bounding required moving the snapshot a node owes **out of the handshake** to run alongside a live receive loop; the old ordering left neither side draining its inbox while sending and deadlocks under a bounded transport (pinned by a capacity-1 mutual-snapshot test). Per-table `needs_snapshot` was considered and **deferred** at `0.2.1` — it needs an on-disk schema change; the cost was instead documented (trim high-volume tables like event logs before enabling replication late). **That residual is closed by `0.3.0`, above.** 188/188 tests at `0.2.1`. **`0.2.0` (2026-08-14) is the performance + lifecycle release from the ScadaBridge architectural review (WP3.3, branch `localdb-perf-remediation`)** — wire-compatible with `0.1.x` peers (no proto change, no `lib_schema_version` bump); the on-disk bookkeeping schema goes to **v2**, upgraded in place on open. Six changes: **`DeregisterReplicated(table)`** drops a table's capture triggers and prunes its oplog + row-version rows (idempotent, and deliberately usable on triggers left by an EARLIER build — there was previously no way to stop capturing once a file had been registered); **`RegisterReplicated(table, baselineExistingRows: true)`** closes the late-opt-in hole, seeding the row-version ledger for pre-existing rows at the LWW floor (HLC `0`, this node's id) and flagging a snapshot resync, because the snapshot streamer pages from that ledger and CDC never wrote pre-registration rows there — a node that started replicating LATE never sent them, silently and forever (the two APIs are a designed pair: the ledger prune is safe only because re-registration can re-baseline; deregistration must still be **symmetric**, which the fail-closed registered-table digest handshake enforces loudly); **byte-budget batching** via `MaxBatchBytes` (default 2 MB) with `MaxBatchSize` demoted to a secondary row cap, plus inbound delta/ack coalescing; **O(1) backlog depth** (`MAX(seq) − MAX(last_acked_seq, MIN(seq) − 1)`) replacing a `COUNT(*)` over the whole backlog that ran per maintenance tick AND per metrics scrape; `LwwApplier` prepare-once-per-statement; and the unused `__localdb_oplog_hlc` index dropped. **`0.1.2` (2026-07-21) fixes snapshot resync for a WIPED peer**: a converged pair prunes every oplog row on ack, and the handshake read an empty oplog as "no gap possible" — so the steady state of a healthy pair was the one state from which a rebuilt node (lost volume, re-imaged host) could never be back-filled; it came back empty and stayed empty until the next deploy. The gap is now measured against `last_acked_seq` when the oplog is empty. Found by the OtOpcUa Phase 1 live gate (check 4) as a documented limitation, then closed. **`0.1.3` (2026-07-21) closes the other half, which only became REACHABLE once back-fill worked**: the rebuilt peer's OWN writes were silently dropped. `last_applied_remote_seq` is a watermark in the *peer's* seq space and a rebuilt peer numbers from 1, so the healthy node's stale watermark made the sending side skip its whole oplog — the pair looked converged right up until the moment only the rebuilt node applied a deploy. Fixed by resetting the inbound watermark on a peer-node-id change (NOT last_acked_seq — that is what 0.1.2 keys off) plus rejecting a peer claim beyond anything we ever produced. Live-gated on the OtOpcUa docker-dev rig: `OtOpcUa/docs/plans/2026-07-21-localdb-followups-live-gate.md`. **`0.1.1` fixes a latent boot defect**: the library never created the parent directory of `LocalDb:Path` and `SqliteLocalDb` opens the file eagerly, so a missing directory was a hard boot failure (`SQLite Error 14`) — routine given the relative `./data/…` defaults, and masked on docker only by the volume mount. ScadaBridge had worked around it app-side; the fix moved into the library and the shim is deleted. **Phase 1** (merged in PR #23): `OperationTracking` + `site_events` consolidated into one `LocalDb:Path` database, fail-closed bearer interceptor on the sync endpoint, `ISyncStatus` health bridge, one-time legacy migrator. **Phase 2** (merged in the same PR): adds `sf_messages` + 7 site config tables (10 replicated in total) and **deletes** ScadaBridge's bespoke `SiteReplicationActor`, its messages, StoreAndForward's `ReplicationService`, and `ReplaceAllAsync` — including the whole **notify-and-fetch** exchange, so a standby now makes NO config fetch on deploy (`SiteReconciliationActor`'s startup fetch survives; different path). `notification_lists`/`smtp_configurations` are created but deliberately NEVER registered — permanently empty, and a channel for them would carry plaintext SMTP passwords. **Live gate PASS, all 10 checks** (`ScadaBridge/docs/plans/2026-07-19-localdb-phase2-live-gate.md`): byte-identical config rows with identical HLC + origin node id, standby zero-fetch, 3-table cascade delete converging with tombstones on both nodes, site failover with zero duplicate ids, clean both-nodes-together restart. **Replication remains default-OFF**, enabled on the docker rig's site-a pair only (site-b/site-c unreplicated as the default-OFF pin); **no production deployment has it enabled.** **Operational constraints:** a site pair must be stopped/started TOGETHER (rolling upgrade unsupported since the `SfBufferSnapshot` compat handler was deleted), a node offline beyond `TombstoneRetention` (7 d) can resurrect deleted rows, and — before `0.2.0` — `MaxBatchSize` batched by ROW COUNT against a 4 MB gRPC cap, which is why the rig pinned it to **16** (the 500 default would allow ~35 MB). **That pin is retired**: ScadaBridge consumed `0.2.0` on 2026-08-14 (branch `arch-review-remediation`), removed `MaxBatchSize` from both `docker/site-a` node configs and left `MaxBatchBytes` at its 2 MB default. The same consumption made `Host/SiteLocalDbSetup.cs` self-healing in both directions: an unreplicated node (site-b/site-c) now **deregisters** all ten tables at boot, dropping stale triggers an earlier build installed, and a replicating node registers with **`baselineExistingRows: true`**, so turning replication ON for a site that had been running without it finally converges on the rows already in the file rather than only on writes made after the restart. **Also adopted by OtOpcUa — Phase 1 COMPLETE and MERGED** to `master` 2026-07-21 (merge `c957db52`), and **Phase 2 (alarm store-and-forward on LocalDb) EXECUTED, live-gated and MERGED** to `master` (merge `06b5d81b`; the earlier "designed but NOT executed" claim was stale — corrected 2026-08-07; residual: live-gate checks 3+4 were never re-run on the post-mesh-Phase-6 topology): a pair-local consolidated SQLite cache of the deployed-configuration artifact (chunked, SHA-256-verified, newest-2 retention) that lets a driver node **boot from cache when central SQL Server is unreachable**, registered in the `hasDriver` branch only (admin-only nodes never register it), with the same default-OFF fail-closed replication. Its live gate passed 8/8 and caught four defects offline tests missed. **Consumers: ScadaBridge (Phases 1+2) and OtOpcUa (Phases 1+2)**, **ScadaBridge pins `0.3.0`** (2026-08-15, residual R1; it consumed `0.2.0` on 2026-08-14 under arch-review WP3.3) and **OtOpcUa pins `0.2.1`** (2026-08-15, residual R4, merged to its `master` @ `5a7aae14` — a pure pin bump, no code change; it also corrected its `MaxBatchSize = 16` rationale, kept at 16 as the DB read-page bound). The remaining 0.2.1-vs-0.3.0 skew is supported: every release is wire-compatible (`0.3.0` negotiates its scoped snapshots by capability and falls back to a full snapshot for an older peer) and the schema v2/v3 upgrades are local-file state. **OtOpcUa closed the ScadaBridge deferred-register row 39 gap on 2026-08-18** (branch `perf/followups-r3`, commit `3e62f161`): `LocalDbSetup.OnReady` no longer registers all four tables unconditionally — registration follows one predicate, `LocalDb:Replication:PeerAddress` **OR** `:ApiKey` non-blank (the OR because only the dialling half of a pair sets `PeerAddress`; `SyncListenPort` is deliberately excluded, a port with no key being fail-closed). A configured node registers with **`baselineExistingRows: true`**, so a late replication enable finally converges on the rows already in the file; an unconfigured node **deregisters** at boot, dropping capture triggers an earlier build installed and logging once only when it actually cleaned something. Same shape ScadaBridge landed under arch-review WP3.3, written against OtOpcUa's own seams. ⚠️ OtOpcUa stays on `0.2.1`, where the snapshot flag is per-DATABASE, so baselining there streams every registered table in full — trim a long `alarm_sf_events` backlog before enabling replication late. Flipping the predicate is a both-nodes-together change (fail-closed registered-table digest handshake). | Shared `ZB.MOM.WW.LocalDb` lib | [`docs/plans/2026-07-17-localdb-design.md`](docs/plans/2026-07-17-localdb-design.md) + [`docs/plans/2026-07-19-scadabridge-localdb-design.md`](docs/plans/2026-07-19-scadabridge-localdb-design.md) + [`docs/plans/2026-07-20-otopcua-localdb-design.md`](docs/plans/2026-07-20-otopcua-localdb-design.md) (both adoption designs live here); ScadaBridge holds the Phase 1 plan + Phase 2 gate under its own `docs/plans/`, OtOpcUa its two phase plans + two live gates | [`ZB.MOM.WW.LocalDb/`](ZB.MOM.WW.LocalDb/) |