diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/DeploymentArtifactService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/DeploymentArtifactService.cs
new file mode 100644
index 00000000..2aa6eab2
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/DeploymentArtifactService.cs
@@ -0,0 +1,92 @@
+using Google.Protobuf;
+using Grpc.Core;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+// The generated service container is itself named DeploymentArtifactService; alias its nested server
+// base so this impl can keep the natural name without colliding with the generated type.
+using GeneratedServiceBase =
+ ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1.DeploymentArtifactService.DeploymentArtifactServiceBase;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
+
+///
+/// Central-side artifact serve (per-cluster mesh Phase 3). Streams a sealed deployment's
+/// ArtifactBlob to a driver node fetching in FetchAndCache mode. Registered only on
+/// admin-role nodes (which hold the central SQL connection) and only when
+/// ConfigServe:GrpcListenPort > 0.
+///
+///
+///
+/// NotFound is deliberately indistinguishable across three cases — unknown id, a
+/// deployment that is not sealed, and an empty blob. The first is existence-hiding; the last
+/// is the #485 rule (a zero-length artifact is "no answer," never "a configuration with no
+/// drivers"). Streaming empty bytes as if valid is exactly what that defect class forbids.
+///
+///
+public sealed class DeploymentArtifactGrpcService : GeneratedServiceBase
+{
+ /// Chunk size, matching LocalDbDeploymentArtifactCache.ChunkSize (128 KiB).
+ private const int ChunkSize = 128 * 1024;
+
+ private readonly IDbContextFactory _dbFactory;
+ private readonly ILogger _log;
+
+ /// Initializes a new instance of the class.
+ /// Factory for the central config database holding the artifact bytes.
+ /// Logger.
+ public DeploymentArtifactGrpcService(
+ IDbContextFactory dbFactory,
+ ILogger log)
+ {
+ _dbFactory = dbFactory;
+ _log = log;
+ }
+
+ ///
+ public override async Task Fetch(
+ FetchRequest request,
+ IServerStreamWriter responseStream,
+ ServerCallContext context)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(responseStream);
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (!Guid.TryParse(request.DeploymentId, out var id))
+ throw new RpcException(new Status(StatusCode.NotFound, "unknown deployment"));
+
+ await using var db = await _dbFactory.CreateDbContextAsync(context.CancellationToken);
+ var row = await db.Deployments
+ .AsNoTracking()
+ .Where(d => d.DeploymentId == id)
+ .Select(d => new { d.Status, d.ArtifactBlob })
+ .FirstOrDefaultAsync(context.CancellationToken);
+
+ // Existence-hiding + #485: unknown / not-sealed / empty collapse to one NotFound.
+ if (row is null || row.Status != DeploymentStatus.Sealed || row.ArtifactBlob.Length == 0)
+ {
+ _log.LogDebug(
+ "Artifact fetch for {DeploymentId} -> NotFound (found={Found}, sealed={Sealed}, bytes={Bytes})",
+ request.DeploymentId, row is not null,
+ row is not null && row.Status == DeploymentStatus.Sealed,
+ row?.ArtifactBlob.Length ?? 0);
+ throw new RpcException(new Status(StatusCode.NotFound, "unknown deployment"));
+ }
+
+ for (var offset = 0; offset < row.ArtifactBlob.Length; offset += ChunkSize)
+ {
+ var len = Math.Min(ChunkSize, row.ArtifactBlob.Length - offset);
+ await responseStream.WriteAsync(
+ new ArtifactChunk { Data = ByteString.CopyFrom(row.ArtifactBlob, offset, len) },
+ context.CancellationToken);
+ }
+
+ _log.LogInformation(
+ "Served artifact for {DeploymentId} ({Bytes} bytes, {Chunks} chunks)",
+ request.DeploymentId, row.ArtifactBlob.Length,
+ (row.ArtifactBlob.Length + ChunkSize - 1) / ChunkSize);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/DeploymentArtifactServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/DeploymentArtifactServiceTests.cs
new file mode 100644
index 00000000..f31d9843
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/DeploymentArtifactServiceTests.cs
@@ -0,0 +1,159 @@
+using Google.Protobuf;
+using Grpc.Core;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
+using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Grpc;
+
+///
+/// The serve side of Phase 3: streams a sealed deployment's bytes, and collapses unknown /
+/// not-sealed / empty into one indistinguishable NotFound (existence-hiding + #485).
+///
+public class DeploymentArtifactServiceTests
+{
+ private static IDbContextFactory Factory(string name) => new NamedFactory(name);
+
+ private static DeploymentArtifactGrpcService Service(string dbName) =>
+ new(Factory(dbName), NullLogger.Instance);
+
+ private static Guid Seed(string dbName, DeploymentStatus status, byte[] blob)
+ {
+ var id = Guid.NewGuid();
+ using var db = new OtOpcUaConfigDbContext(
+ new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options);
+ db.Deployments.Add(new Deployment
+ {
+ DeploymentId = id,
+ RevisionHash = new string('a', 64),
+ Status = status,
+ CreatedBy = "test",
+ ArtifactBlob = blob,
+ });
+ db.SaveChanges();
+ return id;
+ }
+
+ [Fact]
+ public async Task A_sealed_non_empty_blob_streams_back_byte_equal_and_chunk_bounded()
+ {
+ var dbName = $"artsvc-{Guid.NewGuid():N}";
+ // ~300 KB > 2 * 128 KiB -> exactly 3 chunks.
+ var blob = new byte[300 * 1024];
+ for (var i = 0; i < blob.Length; i++) blob[i] = (byte)(i % 251);
+ var id = Seed(dbName, DeploymentStatus.Sealed, blob);
+
+ var writer = new CollectingStreamWriter();
+ await Service(dbName).Fetch(
+ new FetchRequest { DeploymentId = id.ToString() }, writer, new FakeServerCallContext());
+
+ writer.Chunks.Count.ShouldBe(3);
+ writer.Chunks[0].Data.Length.ShouldBe(128 * 1024);
+ writer.Chunks[1].Data.Length.ShouldBe(128 * 1024);
+ writer.Reassembled().ShouldBe(blob);
+ }
+
+ [Fact]
+ public async Task An_unknown_id_throws_NotFound()
+ {
+ var dbName = $"artsvc-{Guid.NewGuid():N}";
+ Seed(dbName, DeploymentStatus.Sealed, [1, 2, 3]);
+
+ var ex = await Should.ThrowAsync(() => Service(dbName).Fetch(
+ new FetchRequest { DeploymentId = Guid.NewGuid().ToString() },
+ new CollectingStreamWriter(), new FakeServerCallContext()));
+
+ ex.StatusCode.ShouldBe(StatusCode.NotFound);
+ }
+
+ [Fact]
+ public async Task A_non_sealed_deployment_throws_NotFound()
+ {
+ var dbName = $"artsvc-{Guid.NewGuid():N}";
+ var id = Seed(dbName, DeploymentStatus.AwaitingApplyAcks, [1, 2, 3]);
+
+ var ex = await Should.ThrowAsync(() => Service(dbName).Fetch(
+ new FetchRequest { DeploymentId = id.ToString() },
+ new CollectingStreamWriter(), new FakeServerCallContext()));
+
+ ex.StatusCode.ShouldBe(StatusCode.NotFound);
+ }
+
+ [Fact]
+ public async Task A_zero_length_blob_throws_NotFound()
+ {
+ // The serve-side #485 guard: never stream empty bytes as if they were a valid empty config.
+ var dbName = $"artsvc-{Guid.NewGuid():N}";
+ var id = Seed(dbName, DeploymentStatus.Sealed, []);
+
+ var ex = await Should.ThrowAsync(() => Service(dbName).Fetch(
+ new FetchRequest { DeploymentId = id.ToString() },
+ new CollectingStreamWriter(), new FakeServerCallContext()));
+
+ ex.StatusCode.ShouldBe(StatusCode.NotFound);
+ }
+
+ [Fact]
+ public async Task A_non_guid_id_throws_NotFound()
+ {
+ var ex = await Should.ThrowAsync(() => Service($"artsvc-{Guid.NewGuid():N}").Fetch(
+ new FetchRequest { DeploymentId = "not-a-guid" },
+ new CollectingStreamWriter(), new FakeServerCallContext()));
+
+ ex.StatusCode.ShouldBe(StatusCode.NotFound);
+ }
+
+ private sealed class CollectingStreamWriter : IServerStreamWriter
+ {
+ public List Chunks { get; } = [];
+ public WriteOptions? WriteOptions { get; set; }
+
+ public Task WriteAsync(ArtifactChunk message)
+ {
+ Chunks.Add(message);
+ return Task.CompletedTask;
+ }
+
+ public byte[] Reassembled()
+ {
+ using var ms = new MemoryStream();
+ foreach (var c in Chunks) c.Data.WriteTo(ms);
+ return ms.ToArray();
+ }
+ }
+
+ private sealed class NamedFactory(string name) : IDbContextFactory
+ {
+ public OtOpcUaConfigDbContext CreateDbContext() =>
+ new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options);
+
+ public Task CreateDbContextAsync(CancellationToken ct = default) =>
+ Task.FromResult(CreateDbContext());
+ }
+
+ /// Minimal — the service reads only the cancellation token.
+ private sealed class FakeServerCallContext : ServerCallContext
+ {
+ protected override string MethodCore => "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
+ protected override string HostCore => "localhost";
+ protected override string PeerCore => "ipv4:127.0.0.1:0";
+ protected override DateTime DeadlineCore => DateTime.MaxValue;
+ protected override Metadata RequestHeadersCore => [];
+ protected override CancellationToken CancellationTokenCore => CancellationToken.None;
+ protected override Metadata ResponseTrailersCore { get; } = [];
+ protected override Status StatusCore { get; set; }
+ protected override WriteOptions? WriteOptionsCore { get; set; }
+ protected override AuthContext AuthContextCore => new(null, new Dictionary>());
+
+ protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
+ throw new NotSupportedException();
+
+ protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
+ }
+}