diff --git a/.gitignore b/.gitignore
index 580c14c..196445f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,6 @@ packages/
.claude/
.local/
+
+# LiteDB local config cache (Phase 6.1 Stream D — runtime artifact, not source)
+src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db
diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
new file mode 100644
index 0000000..3edbfaf
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
@@ -0,0 +1,64 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+///
+/// Process-singleton registry of factories keyed by
+/// DriverInstance.DriverType string. Each driver project ships a DI
+/// extension (e.g. services.AddGalaxyProxyDriverFactory()) that registers
+/// its factory at startup; the bootstrapper looks up the factory by
+/// DriverInstance.DriverType + invokes it with the row's
+/// DriverInstanceId + DriverConfig JSON.
+///
+///
+/// Closes the gap surfaced by task #240 live smoke — DriverInstance rows in
+/// the central config DB had no path to materialise as registered
+/// instances. The factory registry is the seam.
+///
+public sealed class DriverFactoryRegistry
+{
+ private readonly Dictionary> _factories
+ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object _lock = new();
+
+ ///
+ /// Register a factory for . Throws if a factory is
+ /// already registered for that type — drivers are singletons by type-name in
+ /// this process.
+ ///
+ /// Matches DriverInstance.DriverType.
+ ///
+ /// Receives (driverInstanceId, driverConfigJson); returns a new
+ /// . Must NOT call
+ /// itself — the bootstrapper calls it via
+ /// so the host's per-driver retry semantics apply uniformly.
+ ///
+ public void Register(string driverType, Func factory)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
+ ArgumentNullException.ThrowIfNull(factory);
+ lock (_lock)
+ {
+ if (_factories.ContainsKey(driverType))
+ throw new InvalidOperationException(
+ $"DriverType '{driverType}' factory already registered for this process");
+ _factories[driverType] = factory;
+ }
+ }
+
+ ///
+ /// Try to look up the factory for . Returns null
+ /// if no driver assembly registered one — bootstrapper logs + skips so a
+ /// missing-assembly deployment doesn't take down the whole server.
+ ///
+ public Func? TryGet(string driverType)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
+ lock (_lock) return _factories.GetValueOrDefault(driverType);
+ }
+
+ public IReadOnlyCollection RegisteredTypes
+ {
+ get { lock (_lock) return [.. _factories.Keys]; }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs
new file mode 100644
index 0000000..a0a9ae8
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs
@@ -0,0 +1,59 @@
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
+
+///
+/// Static factory registration helper for . Server's
+/// Program.cs calls once at startup; the bootstrapper (task #248)
+/// then materialises Galaxy DriverInstance rows from the central config DB into live
+/// driver instances. No dependency on Microsoft.Extensions.DependencyInjection so the
+/// driver project stays free of DI machinery.
+///
+public static class GalaxyProxyDriverFactoryExtensions
+{
+ public const string DriverTypeName = "Galaxy";
+
+ ///
+ /// Register the Galaxy driver factory in the supplied .
+ /// Throws if 'Galaxy' is already registered — single-instance per process.
+ ///
+ public static void Register(DriverFactoryRegistry registry)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(DriverTypeName, CreateInstance);
+ }
+
+ internal static GalaxyProxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
+
+ // DriverConfig column is a JSON object that mirrors GalaxyProxyOptions.
+ // Required: PipeName, SharedSecret. Optional: ConnectTimeoutMs (defaults to 10s).
+ // The DriverInstanceId from the row wins over any value in the JSON — the row
+ // is the authoritative identity per the schema's UX_DriverInstance_Generation_LogicalId.
+ using var doc = JsonDocument.Parse(driverConfigJson);
+ var root = doc.RootElement;
+
+ string pipeName = root.TryGetProperty("PipeName", out var p) && p.ValueKind == JsonValueKind.String
+ ? p.GetString()!
+ : throw new InvalidOperationException(
+ $"GalaxyProxyDriver config for '{driverInstanceId}' missing required PipeName");
+ string sharedSecret = root.TryGetProperty("SharedSecret", out var s) && s.ValueKind == JsonValueKind.String
+ ? s.GetString()!
+ : throw new InvalidOperationException(
+ $"GalaxyProxyDriver config for '{driverInstanceId}' missing required SharedSecret");
+ var connectTimeout = root.TryGetProperty("ConnectTimeoutMs", out var t) && t.ValueKind == JsonValueKind.Number
+ ? TimeSpan.FromMilliseconds(t.GetInt32())
+ : TimeSpan.FromSeconds(10);
+
+ return new GalaxyProxyDriver(new GalaxyProxyOptions
+ {
+ DriverInstanceId = driverInstanceId,
+ PipeName = pipeName,
+ SharedSecret = sharedSecret,
+ ConnectTimeout = connectTimeout,
+ });
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj
index 862f2da..be33f47 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj
@@ -13,6 +13,7 @@
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs b/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs
new file mode 100644
index 0000000..640670f
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs
@@ -0,0 +1,88 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+namespace ZB.MOM.WW.OtOpcUa.Server;
+
+///
+/// Task #248 — bridges the gap surfaced by the Phase 7 live smoke (#240) where
+/// DriverInstance rows in the central config DB had no path to materialise
+/// as live instances in .
+/// Called from OpcUaServerService.ExecuteAsync after the bootstrap loads
+/// the published generation, before address-space build.
+///
+///
+///
+/// Per row: looks up the DriverType string in
+/// , calls the factory with the row's
+/// DriverInstanceId + DriverConfig JSON to construct an
+/// , then registers via
+/// which invokes InitializeAsync
+/// under the host's lifecycle semantics.
+///
+///
+/// Unknown DriverType = factory not registered = log a warning and skip.
+/// Per plan decision #12 (driver isolation), failure to construct or initialize
+/// one driver doesn't prevent the rest from coming up — the Server keeps serving
+/// the others' subtrees + the operator can fix the misconfigured row + republish
+/// to retry.
+///
+///
+public sealed class DriverInstanceBootstrapper(
+ DriverFactoryRegistry factories,
+ DriverHost driverHost,
+ IServiceScopeFactory scopeFactory,
+ ILogger logger)
+{
+ public async Task RegisterDriversFromGenerationAsync(long generationId, CancellationToken ct)
+ {
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var rows = await db.DriverInstances.AsNoTracking()
+ .Where(d => d.GenerationId == generationId && d.Enabled)
+ .ToListAsync(ct).ConfigureAwait(false);
+
+ var registered = 0;
+ var skippedUnknownType = 0;
+ var failedInit = 0;
+
+ foreach (var row in rows)
+ {
+ var factory = factories.TryGet(row.DriverType);
+ if (factory is null)
+ {
+ logger.LogWarning(
+ "DriverInstance {Id} skipped — DriverType '{Type}' has no registered factory (known: {Known})",
+ row.DriverInstanceId, row.DriverType, string.Join(",", factories.RegisteredTypes));
+ skippedUnknownType++;
+ continue;
+ }
+
+ try
+ {
+ var driver = factory(row.DriverInstanceId, row.DriverConfig);
+ await driverHost.RegisterAsync(driver, row.DriverConfig, ct).ConfigureAwait(false);
+ registered++;
+ logger.LogInformation(
+ "DriverInstance {Id} ({Type}) registered + initialized", row.DriverInstanceId, row.DriverType);
+ }
+ catch (Exception ex)
+ {
+ // Plan decision #12 — driver isolation. Log + continue so one bad row
+ // doesn't deny the OPC UA endpoint to the rest of the fleet.
+ logger.LogError(ex,
+ "DriverInstance {Id} ({Type}) failed to initialize — driver state will reflect Faulted; operator can republish to retry",
+ row.DriverInstanceId, row.DriverType);
+ failedInit++;
+ }
+ }
+
+ logger.LogInformation(
+ "DriverInstanceBootstrapper: gen={Gen} registered={Registered} skippedUnknownType={Skipped} failedInit={Failed}",
+ generationId, registered, skippedUnknownType, failedInit);
+ return registered;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
index 8b7705a..c988ee6 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
@@ -18,6 +18,7 @@ public sealed class OpcUaServerService(
DriverHost driverHost,
OpcUaApplicationHost applicationHost,
DriverEquipmentContentRegistry equipmentContentRegistry,
+ DriverInstanceBootstrapper driverBootstrapper,
Phase7Composer phase7Composer,
IServiceScopeFactory scopeFactory,
ILogger logger) : BackgroundService
@@ -37,6 +38,13 @@ public sealed class OpcUaServerService(
// address space until the first publish, then the registry fills on next restart.
if (result.GenerationId is { } gen)
{
+ // Task #248 — register IDriver instances from the published DriverInstance
+ // rows BEFORE the equipment-content load + Phase 7 compose, so the rest of
+ // the pipeline sees a populated DriverHost. Without this step Phase 7's
+ // CachedTagUpstreamSource has no upstream feed + virtual-tag scripts read
+ // BadNodeIdUnknown for every tag path (gap surfaced by task #240 smoke).
+ await driverBootstrapper.RegisterDriversFromGenerationAsync(gen, stoppingToken);
+
await PopulateEquipmentContentAsync(gen, stoppingToken);
// Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows,
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
index 2cd69a4..956f317 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
using ZB.MOM.WW.OtOpcUa.Server;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
@@ -89,6 +90,18 @@ builder.Services.AddSingleton(_ => new LiteDbConfigCache(opti
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// Task #248 — driver-instance bootstrap pipeline. DriverFactoryRegistry is the
+// type-name → factory map; each driver project's static Register call pre-loads
+// its factory so the bootstrapper can materialise DriverInstance rows from the
+// central DB into live IDriver instances.
+builder.Services.AddSingleton(_ =>
+{
+ var registry = new DriverFactoryRegistry();
+ GalaxyProxyDriverFactoryExtensions.Register(registry);
+ return registry;
+});
+builder.Services.AddSingleton();
+
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
index 9d870a1..c8aa856 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
@@ -34,6 +34,7 @@
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverFactoryRegistryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverFactoryRegistryTests.cs
new file mode 100644
index 0000000..22ad180
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverFactoryRegistryTests.cs
@@ -0,0 +1,73 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
+
+///
+/// Task #248 — covers the contract that
+/// consumes.
+///
+[Trait("Category", "Unit")]
+public sealed class DriverFactoryRegistryTests
+{
+ private static IDriver FakeDriver(string id, string config) => new FakeIDriver(id);
+
+ [Fact]
+ public void Register_then_TryGet_returns_factory()
+ {
+ var r = new DriverFactoryRegistry();
+ r.Register("MyDriver", FakeDriver);
+
+ r.TryGet("MyDriver").ShouldNotBeNull();
+ r.TryGet("Nope").ShouldBeNull();
+ }
+
+ [Fact]
+ public void Register_is_case_insensitive()
+ {
+ var r = new DriverFactoryRegistry();
+ r.Register("Galaxy", FakeDriver);
+ r.TryGet("galaxy").ShouldNotBeNull();
+ r.TryGet("GALAXY").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Register_duplicate_type_throws()
+ {
+ var r = new DriverFactoryRegistry();
+ r.Register("Galaxy", FakeDriver);
+ Should.Throw(() => r.Register("Galaxy", FakeDriver));
+ }
+
+ [Fact]
+ public void Register_null_args_rejected()
+ {
+ var r = new DriverFactoryRegistry();
+ Should.Throw(() => r.Register("", FakeDriver));
+ Should.Throw(() => r.Register("X", null!));
+ }
+
+ [Fact]
+ public void RegisteredTypes_returns_snapshot()
+ {
+ var r = new DriverFactoryRegistry();
+ r.Register("A", FakeDriver);
+ r.Register("B", FakeDriver);
+ r.RegisteredTypes.ShouldContain("A");
+ r.RegisteredTypes.ShouldContain("B");
+ }
+
+ private sealed class FakeIDriver(string id) : IDriver
+ {
+ public string DriverInstanceId => id;
+ public string DriverType => "Fake";
+ public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
+ public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
+ public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
+ public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
+ public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
+ public long GetMemoryFootprint() => 0;
+ }
+}