diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs
new file mode 100644
index 00000000..3e52ab8e
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs
@@ -0,0 +1,264 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Configuration;
+using ZB.MOM.WW.LocalDb;
+
+namespace ZB.MOM.WW.ScadaBridge.Host;
+
+///
+/// One-time copy of the pre-Phase-1 site databases (site-tracking.db and
+/// site_events.db) into the consolidated ZB.MOM.WW.LocalDb database.
+///
+///
+///
+/// Runs after RegisterReplicated, deliberately. Capture is trigger-based, so
+/// rows inserted before registration would never enter the oplog and would never reach the
+/// peer. Migrating after registration means the migrated history replicates like any other
+/// write — which is the entire point of Phase 1.
+///
+///
+/// Idempotent twice over. Tracking rows carry the same TEXT primary key they always
+/// had, so INSERT OR IGNORE is naturally idempotent. Legacy events had autoincrement
+/// integer ids, which the consolidated schema replaced with GUIDs; they are given
+/// deterministic ids of the form mig-{NodeName}-{legacyId} rather than fresh
+/// GUIDs, so a migration that crashes and re-runs cannot duplicate events. The NodeName
+/// prefix keeps the two nodes of a pair from colliding on their independent legacy id
+/// sequences.
+///
+///
+/// All-or-nothing. The copy runs in one transaction and the legacy file is renamed
+/// to <name>.migrated only after commit. A failure throws out of
+/// OnReady, which fails host startup with the legacy files untouched — no
+/// half-migrated state to reason about. A second boot sees the renamed file and no-ops.
+///
+///
+/// Finding nothing is the expected case on the docker rig. Neither legacy config key
+/// is set in any rig appsettings, so both fall back to CWD-relative code defaults
+/// (/app/site-tracking.db, /app/site_events.db) that sit OUTSIDE the mounted
+/// data volume — meaning they were already being discarded on every container recreate.
+/// Phase 1 incidentally fixes that data-loss bug by consolidating into
+/// /app/data/site-localdb.db. A no-op here is a legitimate result, not a failure.
+///
+///
+public static class SiteLocalDbLegacyMigrator
+{
+ private const string MigratedSuffix = ".migrated";
+
+ /// Default legacy tracking connection string (OperationTrackingOptions.ConnectionString).
+ private const string DefaultTrackingConnectionString = "Data Source=site-tracking.db";
+
+ /// Default legacy event-log path (SiteEventLogOptions.DatabasePath).
+ private const string DefaultEventLogPath = "site_events.db";
+
+ ///
+ /// Copies any legacy site databases into , then renames them.
+ ///
+ /// The consolidated site database, with both tables already registered.
+ /// Configuration supplying the legacy paths and the node name.
+ public static void Migrate(ILocalDb db, IConfiguration config)
+ {
+ ArgumentNullException.ThrowIfNull(db);
+ ArgumentNullException.ThrowIfNull(config);
+
+ var nodeName = config["ScadaBridge:Node:NodeName"] ?? "unknown-node";
+
+ MigrateTracking(db, ResolveTrackingPath(config));
+ MigrateEvents(db, ResolveEventLogPath(config), nodeName);
+ }
+
+ ///
+ /// Resolves the legacy tracking database path from the OLD connection-string key,
+ /// falling back to the code default. Relative paths resolve against the process working
+ /// directory — NOT the data volume — because that is where the old code actually put them.
+ ///
+ internal static string ResolveTrackingPath(IConfiguration config)
+ {
+ var connectionString =
+ config["ScadaBridge:OperationTracking:ConnectionString"] ?? DefaultTrackingConnectionString;
+
+ string dataSource;
+ try
+ {
+ dataSource = new SqliteConnectionStringBuilder(connectionString).DataSource;
+ }
+ catch (ArgumentException)
+ {
+ // An unparseable legacy connection string means there is nothing to migrate
+ // from. Do not fail the host over a stale key we are about to stop reading.
+ return string.Empty;
+ }
+
+ // In-memory legacy databases (test/dev configurations) have nothing durable to
+ // migrate, and Path.GetFullPath on them would produce nonsense.
+ if (string.IsNullOrWhiteSpace(dataSource) ||
+ dataSource.Equals(":memory:", StringComparison.OrdinalIgnoreCase) ||
+ dataSource.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Empty;
+ }
+
+ return Path.GetFullPath(dataSource);
+ }
+
+ /// Resolves the legacy event-log path from the OLD key, falling back to the code default.
+ internal static string ResolveEventLogPath(IConfiguration config)
+ {
+ var path = config["ScadaBridge:SiteEventLog:DatabasePath"] ?? DefaultEventLogPath;
+
+ if (string.IsNullOrWhiteSpace(path) ||
+ path.Equals(":memory:", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Empty;
+ }
+
+ return Path.GetFullPath(path);
+ }
+
+ private static void MigrateTracking(ILocalDb db, string legacyPath)
+ {
+ if (!ShouldMigrate(legacyPath)) return;
+
+ var rows = ReadAll(
+ legacyPath,
+ """
+ SELECT TrackedOperationId, Kind, TargetSummary, Status,
+ RetryCount, LastError, HttpStatus,
+ CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc,
+ SourceInstanceId, SourceScript, SourceNode
+ FROM OperationTracking;
+ """,
+ expectedColumns: 13);
+
+ // A legacy file with no OperationTracking table at all reads as "nothing to do";
+ // it still gets renamed so the probe does not repeat on every boot.
+ if (rows is not null)
+ {
+ using var connection = db.CreateConnection();
+ using var transaction = connection.BeginTransaction();
+
+ foreach (var row in rows)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.Transaction = transaction;
+ cmd.CommandText = """
+ INSERT OR IGNORE INTO OperationTracking (
+ TrackedOperationId, Kind, TargetSummary, Status,
+ RetryCount, LastError, HttpStatus,
+ CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc,
+ SourceInstanceId, SourceScript, SourceNode
+ ) VALUES (
+ $p0, $p1, $p2, $p3, $p4, $p5, $p6,
+ $p7, $p8, $p9, $p10, $p11, $p12
+ );
+ """;
+ Bind(cmd, row);
+ cmd.ExecuteNonQuery();
+ }
+
+ transaction.Commit();
+ }
+
+ MarkMigrated(legacyPath);
+ }
+
+ private static void MigrateEvents(ILocalDb db, string legacyPath, string nodeName)
+ {
+ if (!ShouldMigrate(legacyPath)) return;
+
+ var rows = ReadAll(
+ legacyPath,
+ """
+ SELECT id, timestamp, event_type, severity, instance_id, source, message, details
+ FROM site_events;
+ """,
+ expectedColumns: 8);
+
+ if (rows is not null)
+ {
+ using var connection = db.CreateConnection();
+ using var transaction = connection.BeginTransaction();
+
+ foreach (var row in rows)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.Transaction = transaction;
+ cmd.CommandText = """
+ INSERT OR IGNORE INTO site_events (
+ id, timestamp, event_type, severity, instance_id, source, message, details
+ ) VALUES (
+ $id, $p1, $p2, $p3, $p4, $p5, $p6, $p7
+ );
+ """;
+
+ // Deterministic id, NOT a fresh GUID: a crashed-then-rerun migration must
+ // not duplicate events, and INSERT OR IGNORE can only dedupe on a stable key.
+ cmd.Parameters.AddWithValue("$id", $"mig-{nodeName}-{row[0] ?? "null"}");
+ for (var i = 1; i < row.Length; i++)
+ cmd.Parameters.AddWithValue($"$p{i}", row[i] ?? (object)DBNull.Value);
+
+ cmd.ExecuteNonQuery();
+ }
+
+ transaction.Commit();
+ }
+
+ MarkMigrated(legacyPath);
+ }
+
+ /// True when there is a legacy file present that has not already been migrated.
+ private static bool ShouldMigrate(string legacyPath)
+ => !string.IsNullOrEmpty(legacyPath)
+ && File.Exists(legacyPath)
+ && !File.Exists(legacyPath + MigratedSuffix);
+
+ ///
+ /// Reads every row of from the legacy file, or null when the
+ /// table does not exist (an old file predating the table is not an error).
+ ///
+ private static List