diff --git a/ZB.MOM.WW.LocalDb/CLAUDE.md b/ZB.MOM.WW.LocalDb/CLAUDE.md index 0c0f9e7..ed09401 100644 --- a/ZB.MOM.WW.LocalDb/CLAUDE.md +++ b/ZB.MOM.WW.LocalDb/CLAUDE.md @@ -10,6 +10,7 @@ Shared embedded SQLite cache + 2-node LWW sync library for the **ZB.MOM.WW SCADA - **#36 LocalDb-001:** the passive `LocalDbSyncService` now enforces `LocalDb:Replication:ApiKey` itself (Bearer, constant-time, checked before the handshake and before newest-wins takeover; `Unauthenticated` otherwise). Blank key = unchanged behaviour plus a startup warning. ScadaBridge's and OtOpcUa's own `LocalDbSyncAuthInterceptor`s use the same key and header, so they keep working; they are now redundant except for their fail-closed-when-blank rule. - **#37 LocalDb-002:** snapshots deliver FK-linked rows in any order. `SnapshotStreamer` sends referenced tables first (Kahn order, name tie-break); `SnapshotApplier` defers rows that fail only SQLITE_CONSTRAINT_FOREIGNKEY (`LwwApplier.ApplyBatchAsync(..., deferForeignKeyViolations: true)`), retries them as rows land, and dead-letters true orphans at `SnapshotComplete`. Before, a child that arrived first was dead-lettered and acked over permanently. - **#38 LocalDb-003:** the applier upserts with `INSERT … ON CONFLICT() DO UPDATE` (`DO NOTHING` when every column is PK) instead of `INSERT OR REPLACE`. A secondary-`UNIQUE` clash is now a dead-letter instead of a silent delete of another row; as a side effect a remote update of a parent with `ON DELETE RESTRICT` children applies instead of being dead-lettered. +- **#39 LocalDb-004:** `SnapshotApplier` now takes `ReplicationOptions` (wired by `SyncSessionFactory`) and runs the delta path's drift guard per snapshot row (shared `HlcDriftGuard` dead-letter); a dead-lettered row's HLC is kept out of `last_seen_hlc`. Before, `FailClosedOnDrift` was skipped on snapshots and one future-stamped row moved the receiver's HLC ahead across restarts. ## Index history (migrated from the scadaproj root CLAUDE.md) diff --git a/ZB.MOM.WW.LocalDb/README.md b/ZB.MOM.WW.LocalDb/README.md index 6738374..f0ba7a4 100644 --- a/ZB.MOM.WW.LocalDb/README.md +++ b/ZB.MOM.WW.LocalDb/README.md @@ -283,7 +283,11 @@ All options are validated at startup (`ZB.MOM.WW.Configuration` validators). - **Clock-drift guard** — warn-once per session when a peer entry's HLC physical time is more than `MaxHlcDriftAhead` ahead. With `FailClosedOnDrift`, each over-drift entry is dead-lettered; otherwise it is applied (the HLC merge absorbs the skew). The **session - never dies from drift** — no livelock on reconnect. + never dies from drift** — no livelock on reconnect. Since `0.3.6` the guard covers + **snapshot rows** too (warn once per snapshot; dead-lettered under `FailClosedOnDrift`, and + their HLC is neither observed nor persisted as `last_seen_hlc`). Up to `0.3.5` a snapshot + applied every row unchecked, so one future-stamped row could push the receiver's clock + ahead permanently. - **Writes never block on the peer**; local commit is independent of replication. - **All timestamps are UTC** (HLC physical component is UTC Unix milliseconds). diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/HlcDriftGuard.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/HlcDriftGuard.cs new file mode 100644 index 0000000..9560b43 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/HlcDriftGuard.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using ZB.MOM.WW.LocalDb.Internal; + +namespace ZB.MOM.WW.LocalDb.Replication.Internal; + +/// +/// The fail-closed disposition for an inbound row whose HLC runs further ahead of local UTC than +/// allows. Shared by the delta path +/// () and the snapshot path () so both write +/// the same dead-letter row (LocalDb-004). +/// +internal static class HlcDriftGuard +{ + /// The marker every drift dead-letter's error starts with. + public const string ErrorPrefix = "HLC drift ahead"; + + public static Task DeadLetterAsync( + SqliteLocalDb db, OplogEntryRecord entry, long physicalMs, long nowMs, DateTimeOffset now, + TimeSpan maxDriftAhead, CancellationToken ct) => + db.ExecuteAsync( + "INSERT INTO __localdb_dead_letter (received_utc, table_name, pk_json, row_json, hlc, node_id, error) " + + "VALUES (@utc, @t, @pk, @row, @hlc, @node, @err)", + new + { + utc = now.UtcDateTime.ToString(OplogStore.TombstoneUtcFormat, CultureInfo.InvariantCulture), + t = entry.TableName, + pk = entry.PkJson, + row = entry.RowJson, + hlc = entry.Hlc, + node = entry.NodeId, + err = $"{ErrorPrefix}: entry physical time {physicalMs} ms (Unix UTC) exceeds local now {nowMs} ms + MaxHlcDriftAhead {maxDriftAhead} (fail-closed).", + }, ct); +} diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SnapshotStreamer.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SnapshotStreamer.cs index d43153d..3a6c9ca 100644 --- a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SnapshotStreamer.cs +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SnapshotStreamer.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using ZB.MOM.WW.LocalDb.Contracts; +using ZB.MOM.WW.LocalDb.Hlc; using ZB.MOM.WW.LocalDb.Internal; using ZB.MOM.WW.LocalDb.Registration; @@ -246,9 +247,13 @@ internal sealed class SnapshotStreamer(SqliteLocalDb db, OplogStore store, Repli /// internal sealed class SnapshotApplier : ISnapshotApplier { + private readonly SqliteLocalDb _db; private readonly LwwApplier _applier; private readonly OplogStore _store; private readonly ILogger _logger; + private readonly ReplicationOptions _options; + private readonly Func _utcNow; + private bool _driftWarned; private long _asOfSeq; private long _rows; private long _maxHlc; @@ -260,14 +265,27 @@ internal sealed class SnapshotApplier : ISnapshotApplier // sender's debt clears only on the SnapshotAck, so the whole snapshot is re-streamed. private List _pending = []; - // db is part of the collaborator's canonical shape (mirrors the streamer) even though the merge - // reaches the database through the applier and store; kept for construction symmetry in the factory. - public SnapshotApplier(SqliteLocalDb db, LwwApplier applier, OplogStore store, ILogger logger) + /// The receiving database (dead-letter target for over-drift rows). + /// The shared LWW applier. + /// The oplog/peer-state store. + /// Logger. + /// + /// Supplies the drift guard ( / + /// ), applied to snapshot rows exactly as the + /// delta path applies it (LocalDb-004). Defaults to ' defaults: + /// warn, never dead-letter. + /// + /// Test seam for the drift clock. + public SnapshotApplier( + SqliteLocalDb db, LwwApplier applier, OplogStore store, ILogger logger, + ReplicationOptions? options = null, Func? utcNow = null) { - _ = db; + _db = db; _applier = applier; _store = store; _logger = logger; + _options = options ?? new ReplicationOptions(); + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); } public Task OnBeginAsync(SnapshotBegin begin, CancellationToken ct) @@ -276,6 +294,7 @@ internal sealed class SnapshotApplier : ISnapshotApplier _rows = 0; _maxHlc = 0; _pending = []; + _driftWarned = false; // A non-empty table list scopes the snapshot; empty (which is all a pre-0.3.0 sender can // emit) means every registered table. Read here rather than on complete because // SnapshotComplete does not repeat it — the receive loop guarantees a Begin precedes both. @@ -291,12 +310,43 @@ internal sealed class SnapshotApplier : ISnapshotApplier if (batch.Rows.Count == 0) return; + // The same drift guard the delta path runs (SyncSession.ApplyDeltaEntriesAsync). Without it a + // row stamped by a peer whose clock ran far ahead was applied, LwwApplier observed its HLC, + // and OnCompleteAsync persisted it as last_seen_hlc — every later local write on this node + // then carried the future stamp, across restarts, defeating FailClosedOnDrift (LocalDb-004). + var now = _utcNow(); + var nowMs = now.ToUnixTimeMilliseconds(); + var driftLimitMs = nowMs + (long)_options.MaxHlcDriftAhead.TotalMilliseconds; + var entries = new List(batch.Rows.Count); foreach (var row in batch.Rows) { + var entry = new OplogEntryRecord( + 0, batch.TableName, row.PkJson, row.HasRowJson ? row.RowJson : null, row.Hlc, row.NodeId, row.IsTombstone); + var physicalMs = HybridLogicalClock.PhysicalMs(row.Hlc); + if (physicalMs > driftLimitMs) + { + if (!_driftWarned) + { + _driftWarned = true; + _logger.LogWarning( + "Snapshot row HLC physical time {EntryMs} ms is ahead of local UTC {NowMs} ms by more than MaxHlcDriftAhead ({Drift}). Further drift in this snapshot is not re-logged.", + physicalMs, nowMs, _options.MaxHlcDriftAhead); + } + if (_options.FailClosedOnDrift) + { + // Skipped rows contribute nothing to _maxHlc, so last_seen_hlc stays honest. + await HlcDriftGuard.DeadLetterAsync(_db, entry, physicalMs, nowMs, now, _options.MaxHlcDriftAhead, ct); + continue; + } + } if (row.Hlc > _maxHlc) _maxHlc = row.Hlc; - entries.Add(new OplogEntryRecord( - 0, batch.TableName, row.PkJson, row.HasRowJson ? row.RowJson : null, row.Hlc, row.NodeId, row.IsTombstone)); + entries.Add(entry); + } + if (entries.Count == 0) + { + _rows += batch.Rows.Count; + return; } var result = await _applier.ApplyBatchAsync(entries, ct, deferForeignKeyViolations: true); _rows += batch.Rows.Count; diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSession.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSession.cs index d195d25..59da0fe 100644 --- a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSession.cs +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSession.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Threading.Channels; using Microsoft.Extensions.Logging; using ZB.MOM.WW.LocalDb.Contracts; @@ -649,19 +648,7 @@ internal sealed class SyncSession // dead-letters the same entry again (at-least-once — a benign duplicate dead-letter row). private Task DeadLetterDriftedAsync( OplogEntryRecord entry, long physicalMs, long nowMs, DateTimeOffset now, CancellationToken ct) => - _db.ExecuteAsync( - "INSERT INTO __localdb_dead_letter (received_utc, table_name, pk_json, row_json, hlc, node_id, error) " + - "VALUES (@utc, @t, @pk, @row, @hlc, @node, @err)", - new - { - utc = now.UtcDateTime.ToString(OplogStore.TombstoneUtcFormat, CultureInfo.InvariantCulture), - t = entry.TableName, - pk = entry.PkJson, - row = entry.RowJson, - hlc = entry.Hlc, - node = entry.NodeId, - err = $"HLC drift ahead: entry physical time {physicalMs} ms (Unix UTC) exceeds local now {nowMs} ms + MaxHlcDriftAhead {_options.MaxHlcDriftAhead} (fail-closed).", - }, ct); + HlcDriftGuard.DeadLetterAsync(_db, entry, physicalMs, nowMs, now, _options.MaxHlcDriftAhead, ct); private async Task DrainSecondaryAsync(Task task) { diff --git a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSessionFactory.cs b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSessionFactory.cs index 25ab5d1..4c45972 100644 --- a/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSessionFactory.cs +++ b/ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SyncSessionFactory.cs @@ -31,7 +31,7 @@ internal static class SyncSessionFactory var streamer = new SnapshotStreamer(db, store, options, loggerFactory.CreateLogger()); session.SnapshotSender = streamer.SendAsync; - session.SnapshotApplier = new SnapshotApplier(db, applier, store, loggerFactory.CreateLogger()); + session.SnapshotApplier = new SnapshotApplier(db, applier, store, loggerFactory.CreateLogger(), options); return session; } } diff --git a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SnapshotDriftTests.cs b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SnapshotDriftTests.cs new file mode 100644 index 0000000..c953c4d --- /dev/null +++ b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/SnapshotDriftTests.cs @@ -0,0 +1,135 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.LocalDb.Contracts; +using ZB.MOM.WW.LocalDb.Internal; +using ZB.MOM.WW.LocalDb.Replication; +using ZB.MOM.WW.LocalDb.Replication.Internal; + +namespace ZB.MOM.WW.LocalDb.Tests; + +/// +/// LocalDb-004 (scadaproj#39): must hold on the +/// snapshot path too. It was checked only for deltas, so a future-stamped snapshot row was applied, +/// pushed the receiver's HLC a year ahead and persisted that in last_seen_hlc. +/// +public sealed class SnapshotDriftTests : IDisposable +{ + private static readonly TimeSpan RunTimeout = TimeSpan.FromSeconds(15); + + private readonly List _paths = []; + private readonly List _disposables = []; + + public void Dispose() + { + foreach (var d in _disposables) + d.Dispose(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + foreach (var p in _paths) + { + if (File.Exists(p)) File.Delete(p); + if (File.Exists(p + "-wal")) File.Delete(p + "-wal"); + if (File.Exists(p + "-shm")) File.Delete(p + "-shm"); + } + } + + private sealed record Side(SqliteLocalDb Db, OplogStore Store, SyncSession Session); + + private async Task NewDbAsync() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db"); + _paths.Add(path); + var db = new SqliteLocalDb(new LocalDbOptions { Path = path }); + _disposables.Add(db); + await db.ExecuteAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)"); + db.RegisterReplicated("orders"); + return db; + } + + // Built exactly as production does (SyncSessionFactory), so the test covers the real wiring of + // the snapshot applier — the place the options were missing. + private static Side SessionOver(SqliteLocalDb db, ReplicationOptions options) => + new(db, new OplogStore(db, options), SyncSessionFactory.Create(db, options, NullLoggerFactory.Instance)); + + private static (SyncDuplex A, SyncDuplex B) DuplexPair() + { + var aToB = Channel.CreateUnbounded(); + var bToA = Channel.CreateUnbounded(); + return ( + new SyncDuplex { Send = (m, ct) => aToB.Writer.WriteAsync(m, ct).AsTask(), Inbox = bToA.Reader }, + new SyncDuplex { Send = (m, ct) => bToA.Writer.WriteAsync(m, ct).AsTask(), Inbox = aToB.Reader }); + } + + private static long HlcAt(DateTimeOffset utc) => utc.ToUnixTimeMilliseconds() << 16; + + private static async Task> Ids(SqliteLocalDb db) => + (await db.QueryAsync("SELECT id FROM orders ORDER BY id", r => r.GetInt64(0))).ToList(); + + private static async Task Scalar(SqliteLocalDb db, string sql) => + (await db.QueryAsync(sql, r => r.GetInt64(0)))[0]; + + private static async Task WaitForAsync(Func> predicate, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (await predicate()) return; + await Task.Delay(20); + } + throw new TimeoutException("Condition not reached within " + timeout); + } + + private static async Task SwallowAsync(Task task) + { + try { await task; } + catch (OperationCanceledException) { } + } + + /// + /// A wiped B is healed by a snapshot from A, one of whose rows A stamped while its clock ran a + /// year ahead. With FailClosedOnDrift that row must be dead-lettered on B and must not move B's + /// clock; the honest rows still arrive. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FutureStampedSnapshotRow_HonoursFailClosedOnDrift(bool failClosed) + { + var options = new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20), FailClosedOnDrift = failClosed }; + var dbA = await NewDbAsync(); + await dbA.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'ok', 1)"); + await dbA.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'future', 2)"); + var future = HlcAt(DateTimeOffset.UtcNow.AddDays(365)); + await dbA.ExecuteAsync( + "UPDATE __localdb_row_version SET hlc = @hlc WHERE table_name = 'orders' AND pk_json = '{\"id\":2}'", + new { hlc = future }); + var a = SessionOver(dbA, options); + await a.Store.RecordPeerAckAsync(await a.Store.GetMaxSeqAsync()); + + var dbB = await NewDbAsync(); + var b = SessionOver(dbB, options); + + var (da, dbx) = DuplexPair(); + using var cts = new CancellationTokenSource(RunTimeout); + var runA = a.Session.RunAsync(da, cts.Token); + var runB = b.Session.RunAsync(dbx, cts.Token); + await WaitForAsync(async () => await Scalar(dbB, "SELECT last_applied_remote_seq FROM __localdb_peer_state") > 0, RunTimeout); + cts.Cancel(); + await SwallowAsync(runA); + await SwallowAsync(runB); + + var lastSeen = await Scalar(dbB, "SELECT last_seen_hlc FROM __localdb_peer_state"); + if (failClosed) + { + Assert.Equal(new[] { 1L }, await Ids(dbB)); + Assert.Equal(1, await Scalar(dbB, "SELECT COUNT(*) FROM __localdb_dead_letter WHERE error LIKE '%drift%'")); + Assert.True(dbB.Clock.Current < future, "the drifted row advanced the receiver's clock"); + Assert.True(lastSeen < future, "the drifted row's HLC was persisted as last_seen_hlc"); + } + else + { + // Warn-only mode applies it, exactly like the delta path. + Assert.Equal(new[] { 1L, 2L }, await Ids(dbB)); + Assert.Equal(0, await Scalar(dbB, "SELECT COUNT(*) FROM __localdb_dead_letter")); + } + } +} diff --git a/code-reviews/LocalDb/findings.md b/code-reviews/LocalDb/findings.md index f488f3f..e5f054d 100644 --- a/code-reviews/LocalDb/findings.md +++ b/code-reviews/LocalDb/findings.md @@ -10,7 +10,7 @@ | Last reviewed | 2026-11-09 | | Reviewer | Claude | | Commit reviewed | `91fb2b4` | -| Open findings | 4 | +| Open findings | 3 | ## Summary @@ -303,7 +303,7 @@ waits for the watermark. | Severity | Medium | | Priority | P2 | | Category | Spec & shared-contract adherence | -| Status | Open | +| Status | Resolved | | Location | `ZB.MOM.WW.LocalDb/src/ZB.MOM.WW.LocalDb.Replication/Internal/SnapshotStreamer.cs:249` | **Description** @@ -336,7 +336,24 @@ check into `LwwApplier` so both paths share it. Add a snapshot-drift test. **Resolution** -_Unresolved._ +Resolved 2026-11-09 (scadaproj#39, LocalDb `0.3.6`, unpublished). Re-verified with a 2-node test: A served a +snapshot to a wiped B. One of A's rows had a ledger HLC 365 days ahead. With `FailClosedOnDrift = true`, B applied +that row and wrote no dead-letter. + +The fix: `SnapshotApplier` now takes `ReplicationOptions` and a `utcNow` seam, and `SyncSessionFactory` passes the +options. `OnBatchAsync` runs the same per-row check as `SyncSession.ApplyDeltaEntriesAsync`, against +`MaxHlcDriftAhead` and with one warning per snapshot. Under `FailClosedOnDrift`, it dead-letters an over-drift row +through `HlcDriftGuard.DeadLetterAsync`, a new helper that the delta path now uses too. That row's HLC is kept out of +`_maxHlc`, so `OnCompleteAsync` never persists it as `last_seen_hlc`. In warn-only mode, rows are applied, as on the +delta path. + +The check stays in the session and snapshot layers rather than moving into `LwwApplier`. That keeps the delta path's +existing metrics and ack accounting unchanged. + +Tests (`SnapshotDriftTests`): the test is a theory built through `SyncSessionFactory`. In fail-closed mode it checks +that the row is absent and that a drift dead-letter was written. It also checks that the receiver's `Clock.Current` +and `last_seen_hlc` stay below the future stamp; this case fails pre-fix. In warn mode it checks that the row is +applied and that no dead-letter is written. README §Semantics is updated. ### LocalDb-005 — Auto-assigned rowid / AUTOINCREMENT keys collide across nodes and LWW silently drops one row diff --git a/code-reviews/README.md b/code-reviews/README.md index d713d4e..8a6dc57 100644 --- a/code-reviews/README.md +++ b/code-reviews/README.md @@ -40,7 +40,7 @@ code-reviews/ ## Summary -10 of 10 libraries reviewed. 20 pending findings across all libraries. +10 of 10 libraries reviewed. 19 pending findings across all libraries. Coverage: Secrets, LocalDb, GalaxyRepository and Overview first reviewed, and Auth, Health, Theme, Audit, Configuration and Telemetry re-reviewed, on 2026-11-09 (#15). Each finding also @@ -50,9 +50,9 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low). |----------|---------------| | Critical | 0 | | High | 0 | -| Medium | 1 | +| Medium | 0 | | Low | 19 | -| **Total** | **20** | +| **Total** | **19** | ## Library Status @@ -63,7 +63,7 @@ carries a P1/P2/P3 priority (P1 = Critical/High, P2 = Medium, P3 = Low). | [Configuration](Configuration/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 4 | | [GalaxyRepository](GalaxyRepository/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 8 | | [Health](Health/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 7 | -| [LocalDb](LocalDb/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/1/3 | 4 | 7 | +| [LocalDb](LocalDb/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/3 | 3 | 7 | | [Overview](Overview/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 5 | | [Secrets](Secrets/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/13 | 13 | 19 | | [Telemetry](Telemetry/findings.md) | 2026-11-09 | `91fb2b4` | 0/0/0/0 | 0 | 11 | @@ -84,11 +84,9 @@ _None open._ _None open._ -### Medium (1) +### Medium (0) -| ID | Library | Title | -|----|---------|-------| -| LocalDb-004 | [LocalDb](LocalDb/findings.md) | `FailClosedOnDrift` is bypassed on the snapshot path, so a future-stamped snapshot row moves the local HLC forward permanently | +_None open._ ### Low (19)