diff --git a/CLAUDE.md b/CLAUDE.md index 0670cb9..218a79b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,15 +141,15 @@ each project's **code-verified current state**, and the **gaps** between. See | Component | Status | Goal | Design | Implementation | |---|---|---|---|---| -| Auth (login / identity / authz) | Adopted. Feed carries **`0.3.0`** (2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). All four apps pin `0.2.1` (pushed 2026-08-13); ScadaBridge pins `0.3.0` on branch `inbound-api-key-shared-store` (merge pending). `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | +| Auth (login / identity / authz) | Adopted. Feed carries **`0.3.0`** (2026-09-02; 5 packages, newest `ZB.MOM.WW.Auth.ApiKeys.SqlServer`). **All four apps pin `0.3.0`** (2026-09-03, all on their default branch and pushed: OtOpcUa `b8b4c18f`, mxgw `4f931e4`, HistorianGateway `d8d890c`; ScadaBridge already had it on `main` via the `inbound-api-key-shared-store` work, `e45c4af9`). `0.2.1` is a functional fix, not housekeeping — `0.1.5` cannot log in against an AD domain root at all. `FallbackServers` (0.2.0) live-gated on the redundant local GLAuth pair. Version narrative, consumer matrix, `tools/LdapProbe`: [`ZB.MOM.WW.Auth/CLAUDE.md`](ZB.MOM.WW.Auth/CLAUDE.md). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | | UI Theme (layout / tokens / components) | Adopted. Feed `0.2.0`–`0.4.1`; **five** consumers (the ignitionoee router joined), all on `0.4.1` and pushed (2026-08-11); HistorianGateway on wonder still runs Theme `0.3.1` (its 0.4.1 is on `main`, not yet deployed). **On bump, TRIM the local `.btn` block, don't delete it** — mxgw's carried more than sizing. Narrative: [`ZB.MOM.WW.Theme/CLAUDE.md`](ZB.MOM.WW.Theme/CLAUDE.md). | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) | | Health (readiness / liveness / active-node) | **Adopted** — lib `0.3.0`, all 4 apps reference it + wire `MapZbHealth`. `0.2.1`/`0.3.0` changed the active tier's behaviour (standby now answers **503**; active = oldest `Up` member via `ClusterActiveNode`, not `RoleLeader` — the two diverge after any restart), and both Akka apps' private copies were deleted in its favour (merged + pushed). Narrative: [`ZB.MOM.WW.Health/CLAUDE.md`](ZB.MOM.WW.Health/CLAUDE.md). | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) | | Observability (metrics / traces / logs) | **Adopted** (libs `0.1.0`, on the feed; **all 4 apps** call `AddZbTelemetry`; `AddZbSerilog` in all but ScadaBridge, which keeps its own factory by design) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) | | Config + validation (options / startup validation) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed — the "local only / not yet pushed" caveat was stale) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) | | Audit (event model + writer seam) | Adopted (lib `0.1.0`, on the feed; **all 4 apps**, pushed to origin) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) | | Galaxy Repository (object-hierarchy SQL browse + gRPC service) | **Adopted** (feed `0.1.0` + `0.2.0`; consumed at `0.2.0` by HistorianGateway **and mxaccessgw**, whose Server wires `AddZbGalaxyRepository` — verified 2026-07-18; the prior "mxaccessgw adoption is a follow-on" claim was stale) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) | -| Secrets (encrypted store + `${secret:}` resolution) | Built; family version **`0.6.2`**, published. Pins: HistorianGateway `0.6.2` (merged), OtOpcUa `0.6.2` (unpushed), mxgw `0.6.0` (needs the `0.6.1` follow-up), ScadaBridge `0.5.1` (last outstanding). **Target `0.6.1`+, never `0.6.0`, and use the 4-arg `AddZbSecrets(..., contentRootPath)` overload at every pre-host `${secret:}` expander call site — the pin alone does not close the hole.** `Secrets:SqlitePath` must be absolute and outside the content root (fail-closed, no opt-out; the 2026-08-09 mxgw outage). Production topologies decided (scadaproj#3/#4 closed): OtOpcUa = Akka pair-local, ScadaBridge = gRPC hub over ONE shared central SQL store (`0.5.1` minimum for central pairs); both default-off. Full narrative (`0.1.x`→`0.6.2`, incidents, live gates, container caveat, `NU1102` note): [`ZB.MOM.WW.Secrets/CLAUDE.md`](ZB.MOM.WW.Secrets/CLAUDE.md). | Shared `ZB.MOM.WW.Secrets` lib (5 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) | -| LocalDb (embedded cache + 2-node sync) | **Adopted** by ScadaBridge (Phases 1+2 merged; pins **`0.3.4`**) and OtOpcUa (Phases 1+2 merged; pins `0.2.1`) — every release is wire-compatible, so the skew is supported. Replication is **default-OFF**; enabled only on the docker rigs' site-a pairs, **no production deployment has it enabled**. Operational constraints: a pair stops/starts together (no rolling upgrade), a node offline > `TombstoneRetention` (7 d) can resurrect deleted rows. Full release narrative (`0.1.1`→`0.3.4`) + gates: [`ZB.MOM.WW.LocalDb/CLAUDE.md`](ZB.MOM.WW.LocalDb/CLAUDE.md). | Shared `ZB.MOM.WW.LocalDb` lib | [`docs/plans/2026-07-17-localdb-design.md`](docs/plans/2026-07-17-localdb-design.md) + the two adoption designs ([ScadaBridge](docs/plans/2026-07-19-scadabridge-localdb-design.md), [OtOpcUa](docs/plans/2026-07-20-otopcua-localdb-design.md)) | [`ZB.MOM.WW.LocalDb/`](ZB.MOM.WW.LocalDb/) | +| Secrets (encrypted store + `${secret:}` resolution) | Built; family version **`0.6.2`**, published. Pins: **all four apps `0.6.2`** (verified against each repo's checked-in pins AND its origin default branch 2026-09-03 — the earlier "OtOpcUa unpushed / mxgw 0.6.0 / ScadaBridge 0.5.1" notes were stale). **Target `0.6.1`+, never `0.6.0`, and use the 4-arg `AddZbSecrets(..., contentRootPath)` overload at every pre-host `${secret:}` expander call site — the pin alone does not close the hole.** `Secrets:SqlitePath` must be absolute and outside the content root (fail-closed, no opt-out; the 2026-08-09 mxgw outage). Production topologies decided (scadaproj#3/#4 closed): OtOpcUa = Akka pair-local, ScadaBridge = gRPC hub over ONE shared central SQL store (`0.5.1` minimum for central pairs); both default-off. Full narrative (`0.1.x`→`0.6.2`, incidents, live gates, container caveat, `NU1102` note): [`ZB.MOM.WW.Secrets/CLAUDE.md`](ZB.MOM.WW.Secrets/CLAUDE.md). | Shared `ZB.MOM.WW.Secrets` lib (5 packages + CLI) | [`components/secrets/`](components/secrets/) | [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) | +| LocalDb (embedded cache + 2-node sync) | **Adopted** by ScadaBridge (Phases 1+2 merged; pins **`0.3.4`**) and OtOpcUa (Phases 1+2 merged; pins **`0.3.4`** since 2026-09-03, `b8b4c18f`) — no version skew remains (every release is wire-compatible, so one would be supported). Replication is **default-OFF**; enabled only on the docker rigs' site-a pairs, **no production deployment has it enabled**. Operational constraints: a pair stops/starts together (no rolling upgrade), a node offline > `TombstoneRetention` (7 d) can resurrect deleted rows. Full release narrative (`0.1.1`→`0.3.4`) + gates: [`ZB.MOM.WW.LocalDb/CLAUDE.md`](ZB.MOM.WW.LocalDb/CLAUDE.md). | Shared `ZB.MOM.WW.LocalDb` lib | [`docs/plans/2026-07-17-localdb-design.md`](docs/plans/2026-07-17-localdb-design.md) + the two adoption designs ([ScadaBridge](docs/plans/2026-07-19-scadabridge-localdb-design.md), [OtOpcUa](docs/plans/2026-07-20-otopcua-localdb-design.md)) | [`ZB.MOM.WW.LocalDb/`](ZB.MOM.WW.LocalDb/) | Each hosted library directory carries its own `CLAUDE.md` with the full version narrative, consumer matrix and adoption record — it loads automatically when you work under that directory. Health, Telemetry and Configuration diff --git a/ZB.MOM.WW.Auth/CLAUDE.md b/ZB.MOM.WW.Auth/CLAUDE.md index 5f69fe3..1f78d69 100644 --- a/ZB.MOM.WW.Auth/CLAUDE.md +++ b/ZB.MOM.WW.Auth/CLAUDE.md @@ -10,7 +10,7 @@ Shared login / identity / API-key library for the **ZB.MOM.WW SCADA family** (4 | Component | Status | Goal | Design | Implementation | |---|---|---|---|---| -| Auth (login / identity / authz) | Adopted (**feed carries `0.3.0` as of 2026-09-02 — five packages, the new one being `ZB.MOM.WW.Auth.ApiKeys.SqlServer`; ALL FOUR apps still pin `0.2.1` (2026-08-13); ScadaBridge pins `0.3.0` (branch `inbound-api-key-shared-store`, merge pending)**: OtOpcUa `master @ 08e33fc7`, ScadaBridge `main @ d28824d9`, MxAccessGateway `main @ 1cb14d2`, HistorianGateway `e42ff31`, **merged to `main`** — each built + tested green, all PUSHED to origin 2026-08-13). **`0.2.0` (2026-08-10) adds LDAP backup-DC sticky failover**, **`0.2.1` (2026-08-12) fixes a defect that broke AD logins outright** — an AD continuation referral escaped `NovellLdapConnection.Search`, so any `SearchBase` at a domain root failed every login as `ServiceAccountBindFailed` against a perfectly healthy directory. Live-proven against `DC=nam,DC=zimmer,DC=com`; the app-level exposure is closed by the pin bumps. **`FallbackServers` is live-gated** via the local redundant GLAuth pair ([`infra/glauth-redundant/`](infra/glauth-redundant/), primary :3893 + backup :3894 on the dev Mac): HistorianGateway dashboard drill PASS 2026-08-13 (failover, stickiness, walk-back). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | +| Auth (login / identity / authz) | Adopted (**feed carries `0.3.0` as of 2026-09-02 — five packages, the new one being `ZB.MOM.WW.Auth.ApiKeys.SqlServer`; ALL FOUR apps pin `0.3.0` as of 2026-09-03**: OtOpcUa `master @ b8b4c18f`, ScadaBridge `main @ e45c4af9` (the `inbound-api-key-shared-store` work, merged), MxAccessGateway `main @ 4f931e4`, HistorianGateway `main @ d8d890c` — each built + tested with no new failures versus its pre-bump baseline, all PUSHED to origin. The preceding `0.2.1` pins landed 2026-08-13 (OtOpcUa `08e33fc7`, ScadaBridge `d28824d9`, MxAccessGateway `1cb14d2`, HistorianGateway `e42ff31`). **`0.2.0` (2026-08-10) adds LDAP backup-DC sticky failover**, **`0.2.1` (2026-08-12) fixes a defect that broke AD logins outright** — an AD continuation referral escaped `NovellLdapConnection.Search`, so any `SearchBase` at a domain root failed every login as `ServiceAccountBindFailed` against a perfectly healthy directory. Live-proven against `DC=nam,DC=zimmer,DC=com`; the app-level exposure is closed by the pin bumps. **`FallbackServers` is live-gated** via the local redundant GLAuth pair ([`infra/glauth-redundant/`](infra/glauth-redundant/), primary :3893 + backup :3894 on the dev Mac): HistorianGateway dashboard drill PASS 2026-08-13 (failover, stickiness, walk-back). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | ### Narrative @@ -29,7 +29,7 @@ committed + reviewed, then **fast-forward-merged into the repo's local default ( (gitea) on 2026-06-03** (in sync; the `feat/*` branches kept locally as history). Cutover: shared `Auth.Ldap`, `Auth.ApiKeys` (ScadaBridge inbound fully re-architected to the keyId/Bearer model), `IGroupRoleMapper` seam, `Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the -canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: **all four apps pin `0.2.1` as of 2026-08-13** (commits listed in the table row above, all pushed to origin). Prior history — all four pinned `0.1.5` (verified 2026-08-07): MxGateway via `e107019` 2026-07-18 (earlier bumped `0.1.2`→`0.1.4` 2026-07-09 for the G-2 API-key-expiry verifier enforcement — commit `197731a`; also required implementing the 0.1.3 `IApiKeyAdminStore.SetScopes/SetEnabled` members on a test fake), ScadaBridge `50d79ed1` + HistorianGateway `df211ee` 2026-07-18, OtOpcUa `1adaa2fc` 2026-07-20 (PR #484, family alignment from `0.1.1`). Per-repo detail in [`components/auth/GAPS.md`](components/auth/GAPS.md) + +canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: **all four apps pin `0.3.0` as of 2026-09-03** (commits listed in the table row above, all pushed to origin); all four pinned `0.2.1` from 2026-08-13 before that. Prior history — all four pinned `0.1.5` (verified 2026-08-07): MxGateway via `e107019` 2026-07-18 (earlier bumped `0.1.2`→`0.1.4` 2026-07-09 for the G-2 API-key-expiry verifier enforcement — commit `197731a`; also required implementing the 0.1.3 `IApiKeyAdminStore.SetScopes/SetEnabled` members on a test fake), ScadaBridge `50d79ed1` + HistorianGateway `df211ee` 2026-07-18, OtOpcUa `1adaa2fc` 2026-07-20 (PR #484, family alignment from `0.1.1`). Per-repo detail in [`components/auth/GAPS.md`](components/auth/GAPS.md) + `docs/plans/2026-06-02-auth-audit-normalization*.md`. Build/test from `ZB.MOM.WW.Auth/`: `dotnet test` (the `ApiKeys.SqlServer` live suite is gated on `AUTH_SQLSERVER_CONNSTR`, skipped otherwise). Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore; MxAccessGateway & HistorianGateway → the original four; ScadaBridge → those four **plus `ApiKeys.SqlServer`** (ApiKeys not used by OtOpcUa). diff --git a/ZB.MOM.WW.LocalDb/CLAUDE.md b/ZB.MOM.WW.LocalDb/CLAUDE.md index b3873f2..e624bf9 100644 --- a/ZB.MOM.WW.LocalDb/CLAUDE.md +++ b/ZB.MOM.WW.LocalDb/CLAUDE.md @@ -10,4 +10,4 @@ Shared embedded SQLite cache + 2-node LWW sync library for the **ZB.MOM.WW SCADA | 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/) | +| 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 remained `0.2.1` (until its 2026-09-03 re-pin to `0.3.4`) — 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 remained `0.2.1` (until its 2026-09-03 re-pin to `0.3.4`). **`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 remained `0.2.1` (until its 2026-09-03 re-pin to `0.3.4`). **`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 remained `0.2.1` (until its 2026-09-03 re-pin to `0.3.4`).** **`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)** — **both pin `0.3.4`** as of 2026-09-03 (ScadaBridge since 2026-08-20; OtOpcUa `b8b4c18f`, a pure pin bump with tests unchanged versus its baseline). Earlier: **ScadaBridge pinned `0.3.0`** (2026-08-15, residual R1; it consumed `0.2.0` on 2026-08-14 under arch-review WP3.3) and **OtOpcUa pinned `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 re-pinned to `0.3.4` on 2026-09-03 (`b8b4c18f`), so its snapshot flag is per-table too and the earlier per-DATABASE caveat (a late baseline streamed every registered table in full) no longer applies. 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/) |