diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs
new file mode 100644
index 00000000..92a0f3ab
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/HttpDeploymentConfigFetcher.cs
@@ -0,0 +1,57 @@
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
+
+/// HTTP — GETs the config from central's internal endpoint.
+public sealed class HttpDeploymentConfigFetcher : IDeploymentConfigFetcher
+{
+ private readonly HttpClient _http;
+ private readonly ILogger _log;
+
+ public HttpDeploymentConfigFetcher(HttpClient http, ILogger log)
+ {
+ _http = http;
+ _log = log;
+ }
+
+ public async Task FetchAsync(string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
+ {
+ var url = $"{centralFetchBaseUrl.TrimEnd('/')}/api/internal/deployments/{Uri.EscapeDataString(deploymentId)}/config";
+ using var req = new HttpRequestMessage(HttpMethod.Get, url);
+ req.Headers.Add("X-Deployment-Token", token);
+
+ HttpResponseMessage resp;
+ try
+ {
+ resp = await _http.SendAsync(req, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw; // genuine caller cancellation — propagate
+ }
+ catch (Exception ex)
+ {
+ // Includes HttpClient.Timeout (surfaces as TaskCanceledException with ct NOT cancelled) — treat as transport.
+ _log.LogWarning(ex, "deployment-config fetch transport error for {DeploymentId}", deploymentId);
+ throw new DeploymentConfigFetchException($"deployment-config fetch transport error: {ex.Message}", isSuperseded: false, ex);
+ }
+
+ using (resp)
+ {
+ if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ _log.LogWarning("deployment-config not found (expired/superseded/unknown) for {DeploymentId} (HTTP 404)", deploymentId);
+ throw new DeploymentConfigFetchException($"deployment-config not found (expired/superseded/unknown): {deploymentId}", isSuperseded: true);
+ }
+ if (!resp.IsSuccessStatusCode)
+ {
+ _log.LogWarning("deployment-config fetch failed for {DeploymentId}: HTTP {StatusCode}", deploymentId, (int)resp.StatusCode);
+ throw new DeploymentConfigFetchException($"deployment-config fetch failed: HTTP {(int)resp.StatusCode}", isSuperseded: false);
+ }
+
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ _log.LogDebug("deployment-config fetched for {DeploymentId} (HTTP {StatusCode})", deploymentId, (int)resp.StatusCode);
+ return body;
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/IDeploymentConfigFetcher.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/IDeploymentConfigFetcher.cs
new file mode 100644
index 00000000..7b07d210
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/IDeploymentConfigFetcher.cs
@@ -0,0 +1,24 @@
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
+
+///
+/// Fetches a deployment's flattened config JSON from central over HTTP
+/// (notify-and-fetch). The site calls this after receiving a small notify.
+///
+public interface IDeploymentConfigFetcher
+{
+ ///
+ /// GETs the flattened config JSON for from
+ /// , presenting
+ /// in the X-Deployment-Token header. Throws
+ /// on any non-success; a 404 (expired/superseded/unknown) sets IsSuperseded.
+ ///
+ Task FetchAsync(string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct);
+}
+
+/// Raised when a deployment-config fetch fails. is true on HTTP 404.
+public sealed class DeploymentConfigFetchException : Exception
+{
+ public bool IsSuperseded { get; }
+ public DeploymentConfigFetchException(string message, bool isSuperseded, Exception? inner = null)
+ : base(message, inner) => IsSuperseded = isSuperseded;
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
index ca63217a..f0fc0a2f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -63,6 +64,12 @@ public static class ServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
+ // Notify-and-fetch: typed HttpClient for fetching deployment configs from central.
+ services.AddHttpClient()
+ .ConfigureHttpClient((sp, c) =>
+ c.Timeout = TimeSpan.FromSeconds(
+ sp.GetRequiredService>().Value.ConfigFetchTimeoutSeconds));
+
return services;
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs
index 967ad376..4c6258a9 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs
@@ -56,4 +56,13 @@ public class SiteRuntimeOptions
/// Default: 5000ms.
///
public int NativeAlarmRetryIntervalMs { get; set; } = 5000;
+
+ /// HTTP timeout (seconds) for fetching a deployment config from central (notify-and-fetch).
+ public int ConfigFetchTimeoutSeconds { get; set; } = 30;
+
+ ///
+ /// Bounded retry count for the standby's best-effort replicated-config fetch.
+ /// Reserved — consumed by the standby replication fetch in a later task; not yet wired.
+ ///
+ public int ConfigFetchRetryCount { get; set; } = 3;
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj
index 0ffcb370..e28e22a1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj
@@ -16,6 +16,7 @@
+
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs
new file mode 100644
index 00000000..5d99c60d
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/HttpDeploymentConfigFetcherTests.cs
@@ -0,0 +1,233 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Net;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment;
+
+///
+/// Unit tests for using a stub
+/// . No live HTTP connections are made.
+///
+public class HttpDeploymentConfigFetcherTests
+{
+ private static HttpDeploymentConfigFetcher CreateFetcher(HttpMessageHandler handler)
+ {
+ var client = new HttpClient(handler);
+ return new HttpDeploymentConfigFetcher(client, NullLogger.Instance);
+ }
+
+ // -------------------------------------------------------------------------
+ // Happy path
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_200_ReturnsBody()
+ {
+ const string expectedBody = """{"instanceUniqueName":"test"}""";
+ var handler = new StubHandler(HttpStatusCode.OK, expectedBody);
+ var fetcher = CreateFetcher(handler);
+
+ var result = await fetcher.FetchAsync("http://central", "dep-1", "tok-abc", CancellationToken.None);
+
+ Assert.Equal(expectedBody, result);
+ }
+
+ [Fact]
+ public async Task FetchAsync_200_RequestUrlIsCorrect()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK, "{}");
+ var fetcher = CreateFetcher(handler);
+
+ await fetcher.FetchAsync("http://central", "dep-abc", "tok", CancellationToken.None);
+
+ Assert.NotNull(handler.LastRequest);
+ Assert.Equal(
+ "http://central/api/internal/deployments/dep-abc/config",
+ handler.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task FetchAsync_200_SetsDeploymentTokenHeader()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK, "{}");
+ var fetcher = CreateFetcher(handler);
+
+ await fetcher.FetchAsync("http://central", "dep-1", "my-secret-token", CancellationToken.None);
+
+ Assert.NotNull(handler.LastRequest);
+ Assert.True(handler.LastRequest!.Headers.Contains("X-Deployment-Token"));
+ Assert.Equal("my-secret-token", handler.LastRequest.Headers.GetValues("X-Deployment-Token").Single());
+ }
+
+ [Fact]
+ public async Task FetchAsync_BaseUrlWithTrailingSlash_NoDoubleSlashInUrl()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK, "{}");
+ var fetcher = CreateFetcher(handler);
+
+ await fetcher.FetchAsync("http://central/", "dep-1", "tok", CancellationToken.None);
+
+ Assert.NotNull(handler.LastRequest);
+ var url = handler.LastRequest!.RequestUri!.ToString();
+ Assert.DoesNotContain("//api", url);
+ Assert.Equal("http://central/api/internal/deployments/dep-1/config", url);
+ }
+
+ [Fact]
+ public async Task FetchAsync_DeploymentIdNeedingEscaping_EscapesOnceInUrl()
+ {
+ var handler = new StubHandler(HttpStatusCode.OK, "{}");
+ var fetcher = CreateFetcher(handler);
+
+ // A deploymentId containing a space and a slash — both require escaping.
+ await fetcher.FetchAsync("http://central", "dep a/b", "tok", CancellationToken.None);
+
+ Assert.NotNull(handler.LastRequest);
+ // Uri.OriginalString preserves the escaped form as built (no normalization/decoding).
+ var url = handler.LastRequest!.RequestUri!.OriginalString;
+ Assert.Equal("http://central/api/internal/deployments/dep%20a%2Fb/config", url);
+ // Not double-encoded: the percent-sign itself must not be re-escaped to %25.
+ Assert.DoesNotContain("%25", url);
+ }
+
+ // -------------------------------------------------------------------------
+ // 404 → IsSuperseded = true
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_404_ThrowsWithIsSupersededTrue()
+ {
+ var handler = new StubHandler(HttpStatusCode.NotFound, "Not found");
+ var fetcher = CreateFetcher(handler);
+
+ var ex = await Assert.ThrowsAsync(
+ () => fetcher.FetchAsync("http://central", "dep-gone", "tok", CancellationToken.None));
+
+ Assert.True(ex.IsSuperseded);
+ Assert.Contains("dep-gone", ex.Message);
+ }
+
+ // -------------------------------------------------------------------------
+ // Other HTTP errors → IsSuperseded = false
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_401_ThrowsWithIsSupersededFalse()
+ {
+ var handler = new StubHandler(HttpStatusCode.Unauthorized, "Unauthorized");
+ var fetcher = CreateFetcher(handler);
+
+ var ex = await Assert.ThrowsAsync(
+ () => fetcher.FetchAsync("http://central", "dep-1", "bad-tok", CancellationToken.None));
+
+ Assert.False(ex.IsSuperseded);
+ Assert.Contains("401", ex.Message);
+ }
+
+ [Fact]
+ public async Task FetchAsync_500_ThrowsWithIsSupersededFalse()
+ {
+ var handler = new StubHandler(HttpStatusCode.InternalServerError, "Server error");
+ var fetcher = CreateFetcher(handler);
+
+ var ex = await Assert.ThrowsAsync(
+ () => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
+
+ Assert.False(ex.IsSuperseded);
+ Assert.Contains("500", ex.Message);
+ }
+
+ // -------------------------------------------------------------------------
+ // Transport failure → DeploymentConfigFetchException (not raw HttpRequestException)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_TransportException_WrapsAsDeploymentConfigFetchException()
+ {
+ var handler = new ThrowingHandler(new HttpRequestException("Connection refused"));
+ var fetcher = CreateFetcher(handler);
+
+ var ex = await Assert.ThrowsAsync(
+ () => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
+
+ Assert.False(ex.IsSuperseded);
+ Assert.IsType(ex.InnerException);
+ Assert.Contains("Connection refused", ex.Message);
+ }
+
+ [Fact]
+ public async Task FetchAsync_HttpClientTimeout_WrapsAsTransportError_NotCancellation()
+ {
+ // HttpClient.Timeout surfaces as TaskCanceledException while the caller's ct is NOT cancelled.
+ var handler = new ThrowingHandler(new TaskCanceledException("simulated HttpClient timeout"));
+ var fetcher = CreateFetcher(handler);
+
+ var ex = await Assert.ThrowsAsync(
+ () => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
+
+ Assert.False(ex.IsSuperseded);
+ Assert.IsType(ex.InnerException);
+ }
+
+ // -------------------------------------------------------------------------
+ // Cancellation propagates as-is
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public async Task FetchAsync_CancelledToken_PropagatesOperationCanceledException()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var handler = new DelayHandler(); // never returns
+ var fetcher = CreateFetcher(handler);
+
+ await Assert.ThrowsAnyAsync(
+ () => fetcher.FetchAsync("http://central", "dep-1", "tok", cts.Token));
+ }
+
+ // -------------------------------------------------------------------------
+ // Stub handlers
+ // -------------------------------------------------------------------------
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ private readonly HttpStatusCode _status;
+ private readonly string _body;
+ public HttpRequestMessage? LastRequest { get; private set; }
+
+ public StubHandler(HttpStatusCode status, string body)
+ {
+ _status = status;
+ _body = body;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ LastRequest = request;
+ var resp = new HttpResponseMessage(_status)
+ {
+ Content = new StringContent(_body)
+ };
+ return Task.FromResult(resp);
+ }
+ }
+
+ private sealed class ThrowingHandler : HttpMessageHandler
+ {
+ private readonly Exception _ex;
+ public ThrowingHandler(Exception ex) => _ex = ex;
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ => throw _ex;
+ }
+
+ private sealed class DelayHandler : HttpMessageHandler
+ {
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+ }
+}