diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeploymentArtifactFetchBoundaryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeploymentArtifactFetchBoundaryTests.cs
new file mode 100644
index 00000000..f28df672
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeploymentArtifactFetchBoundaryTests.cs
@@ -0,0 +1,181 @@
+using System.Net;
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
+using ZB.MOM.WW.OtOpcUa.Cluster;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// Per-cluster mesh Phase 3 (Task 8) — the REAL gRPC artifact-fetch boundary. An in-memory fake
+/// client (Task 4) proves the fetcher's logic; this proves a real stream crosses a real Kestrel
+/// h2c listener through the real into the real
+/// and back through the real
+/// .
+///
+///
+/// The Phase-2 lesson applied: a passing fake client cannot distinguish "the boundary works" from
+/// "something else supplied the bytes". The wrong-key control (assertion 2) is the falsifier — it
+/// must return null on the SAME server where the right key succeeds.
+///
+[Trait("Category", "ArtifactBoundary")]
+public sealed class DeploymentArtifactFetchBoundaryTests
+{
+ private const string ApiKey = "the-shared-node-key";
+
+ [Fact]
+ public async Task RightKey_FetchesAndReassembles_ByteEqual_AndVerifiesAgainstTheRevisionHash()
+ {
+ // >256 KB so the stream really chunks (3 × 128 KiB).
+ var blob = RandomBlob(300 * 1024);
+ var revisionHash = Sha256Hex(blob);
+
+ var (server, deploymentId, port) = await StartServerAsync(revisionHash, blob);
+ await using var _ = server;
+
+ var fetcher = Fetcher([$"http://localhost:{port}"], ApiKey);
+ var result = await fetcher.FetchAsync(deploymentId, revisionHash, TestContext.Current.CancellationToken);
+
+ result.ShouldBe(blob);
+ }
+
+ [Fact]
+ public async Task WrongKey_ReturnsNull_WhileTheRightKeySucceedsOnTheSameServer()
+ {
+ var blob = RandomBlob(4096);
+ var revisionHash = Sha256Hex(blob);
+
+ var (server, deploymentId, port) = await StartServerAsync(revisionHash, blob);
+ await using var _ = server;
+
+ // Falsifiability control: the interceptor rejects a wrong key -> the fetcher gets an RpcException
+ // -> null. Without this, a green right-key fetch cannot prove the boundary is what carries bytes.
+ var wrong = await Fetcher([$"http://localhost:{port}"], "not-the-key")
+ .FetchAsync(deploymentId, revisionHash, TestContext.Current.CancellationToken);
+ wrong.ShouldBeNull();
+
+ // Same server, right key: succeeds — proving the null above was the auth gate, not a dead server.
+ var right = await Fetcher([$"http://localhost:{port}"], ApiKey)
+ .FetchAsync(deploymentId, revisionHash, TestContext.Current.CancellationToken);
+ right.ShouldBe(blob);
+ }
+
+ [Fact]
+ public async Task AnUnknownDeploymentId_ReturnsNull()
+ {
+ var blob = RandomBlob(4096);
+ var (server, _, port) = await StartServerAsync(Sha256Hex(blob), blob);
+ await using var __ = server;
+
+ var result = await Fetcher([$"http://localhost:{port}"], ApiKey)
+ .FetchAsync(Guid.NewGuid().ToString(), Sha256Hex(blob), TestContext.Current.CancellationToken);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Failover_FromADeadEndpoint_ToTheRealServer_StillReturnsTheBytes()
+ {
+ var blob = RandomBlob(4096);
+ var revisionHash = Sha256Hex(blob);
+
+ var (server, deploymentId, port) = await StartServerAsync(revisionHash, blob);
+ await using var _ = server;
+ var deadPort = GetFreePort(); // nothing listening
+
+ var fetcher = Fetcher([$"http://localhost:{deadPort}", $"http://localhost:{port}"], ApiKey);
+ var result = await fetcher.FetchAsync(deploymentId, revisionHash, TestContext.Current.CancellationToken);
+
+ result.ShouldBe(blob);
+ }
+
+ // ---- harness --------------------------------------------------------------------------------
+
+ ///
+ /// Stands a minimal real Host: an h2c-only Kestrel listener serving the real
+ /// behind the real ,
+ /// backed by an in-memory config DB seeded with one sealed deployment.
+ ///
+ private static async Task<(WebApplication Server, string DeploymentId, int Port)> StartServerAsync(
+ string revisionHash, byte[] blob)
+ {
+ var port = GetFreePort();
+ var id = Guid.NewGuid();
+ var dbName = $"artboundary-{id:N}";
+
+ var builder = WebApplication.CreateBuilder();
+ builder.Environment.ApplicationName = typeof(DeploymentArtifactFetchBoundaryTests).Assembly.GetName().Name!;
+ builder.Logging.ClearProviders();
+
+ builder.Services.Configure(o => o.ApiKey = ApiKey);
+ builder.Services.AddGrpc(o => o.Interceptors.Add());
+ builder.Services.AddDbContextFactory(o => o.UseInMemoryDatabase(dbName));
+
+ builder.WebHost.ConfigureKestrel(k =>
+ k.ListenAnyIP(port, o => o.Protocols = HttpProtocols.Http2));
+
+ var app = builder.Build();
+ app.MapGrpcService();
+
+ // Seed the sealed deployment the service will serve.
+ await using (var scope = app.Services.CreateAsyncScope())
+ {
+ var factory = scope.ServiceProvider.GetRequiredService>();
+ await using var db = await factory.CreateDbContextAsync(TestContext.Current.CancellationToken);
+ db.Deployments.Add(new Deployment
+ {
+ DeploymentId = id,
+ RevisionHash = revisionHash,
+ Status = DeploymentStatus.Sealed,
+ CreatedBy = "test",
+ ArtifactBlob = blob,
+ });
+ await db.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ await app.StartAsync(TestContext.Current.CancellationToken);
+ return (app, id.ToString(), port);
+ }
+
+ private static GrpcDeploymentArtifactFetcher Fetcher(string[] endpoints, string apiKey)
+ => new(
+ Options.Create(new ConfigSourceOptions
+ {
+ Mode = ConfigSourceOptions.ModeFetchAndCache,
+ CentralFetchEndpoints = endpoints,
+ ApiKey = apiKey,
+ FetchTimeoutSeconds = 15,
+ }),
+ NullLogger.Instance);
+
+ private static byte[] RandomBlob(int size)
+ {
+ var blob = new byte[size];
+ for (var i = 0; i < size; i++) blob[i] = (byte)(i * 31 + 7);
+ return blob;
+ }
+
+ private static string Sha256Hex(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
+
+ private static int GetFreePort()
+ {
+ using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}