diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSaga.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSaga.cs
new file mode 100644
index 000000000..a4753bb0d
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSaga.cs
@@ -0,0 +1,22 @@
+namespace Wolverine.Grpc.Tests.SagaOverGrpc;
+
+///
+/// A header-identified saga: carries no id member,
+/// so Wolverine resolves the saga id from the inbound envelope's saga-id header. Over a
+/// gRPC service hop that header is never populated — neither the client nor server propagation
+/// interceptor carries saga-id, and Executor.InvokeAsync<T> does not seed it
+/// onto the invoked envelope — so this saga reproduces the IndeterminateSagaStateIdException
+/// gap (GH-3385). StartOrHandle takes the "maybe-existing" code path, which pulls the id
+/// from the envelope on the very first call, so a single RPC is enough to reproduce.
+///
+public class CountingSaga : Saga
+{
+ public string Id { get; set; } = string.Empty;
+ public int Count { get; set; }
+
+ public StartCountingReply StartOrHandle(StartCountingRequest request)
+ {
+ Count++;
+ return new StartCountingReply { SagaId = Id };
+ }
+}
diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSagaGrpcService.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSagaGrpcService.cs
new file mode 100644
index 000000000..112d25c38
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSagaGrpcService.cs
@@ -0,0 +1,19 @@
+using ProtoBuf.Grpc;
+using Wolverine.Grpc;
+
+namespace Wolverine.Grpc.Tests.SagaOverGrpc;
+
+///
+/// Code-first gRPC service that forwards to the Wolverine bus
+/// exactly the way the docs show — Bus.InvokeAsync<TResponse>(request, ct). The saga
+/// on the other side is an ordinary Wolverine handler; nothing here is saga-aware.
+///
+public class CountingSagaGrpcService : WolverineGrpcServiceBase, ICountingSagaService
+{
+ public CountingSagaGrpcService(IMessageBus bus) : base(bus)
+ {
+ }
+
+ public Task Start(StartCountingRequest request, CallContext context = default)
+ => Bus.InvokeAsync(request, context.CancellationToken);
+}
diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSaga.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSaga.cs
new file mode 100644
index 000000000..bf2d7eaf2
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSaga.cs
@@ -0,0 +1,30 @@
+namespace Wolverine.Grpc.Tests.SagaOverGrpc;
+
+///
+/// A message-identified saga: it is started by (id from the
+/// message) and continued by (id from the message). No
+/// envelope saga-id header is involved, so this is the case that "works just like HTTP"
+/// over a gRPC hop. Both handlers return a reply so the forwarding
+/// Bus.InvokeAsync<TResponse> has a response to hand back to the RPC caller.
+///
+public class ReservationSaga : Saga
+{
+ public string Id { get; set; } = string.Empty;
+ public bool Booked { get; set; }
+
+ public ReservationBookedReply Start(StartReservationRequest start)
+ {
+ Id = start.ReservationId!;
+ return new ReservationBookedReply { ReservationId = start.ReservationId };
+ }
+
+ public BookReservationReply Handle(BookReservationRequest book)
+ {
+ Booked = true;
+
+ // Done — Wolverine deletes the saga state at the end of message handling.
+ MarkCompleted();
+
+ return new BookReservationReply { Completed = true };
+ }
+}
diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaContracts.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaContracts.cs
new file mode 100644
index 000000000..3858e9980
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaContracts.cs
@@ -0,0 +1,50 @@
+using ProtoBuf;
+using ProtoBuf.Grpc;
+using System.ServiceModel;
+
+namespace Wolverine.Grpc.Tests.SagaOverGrpc;
+
+///
+/// Code-first gRPC contract for a message-identified saga — the id rides on the request
+/// DTO ( / ),
+/// exactly like the HTTP saga sample (WolverineWebApi.SagaExample). This is the case Jeremy
+/// expects to "work just like HTTP", and it does — the gRPC shim forwards to the same
+/// InvokeAsync pipeline. Mirrors
+/// Wolverine.Http.Tests.building_a_saga_and_publishing_other_messages_from_http_endpoint.
+///
+[ServiceContract]
+public interface IReservationSagaService
+{
+ Task Start(StartReservationRequest request, CallContext context = default);
+ Task Book(BookReservationRequest request, CallContext context = default);
+}
+
+[ProtoContract]
+public class StartReservationRequest
+{
+ // Resolves to the saga id: "Reservation" is the ReservationSaga name minus the "Saga" suffix,
+ // so "ReservationId" is matched by SagaChain.DetermineSagaIdMember off the message body.
+ [ProtoMember(1)]
+ public string? ReservationId { get; set; }
+}
+
+[ProtoContract]
+public class ReservationBookedReply
+{
+ [ProtoMember(1)]
+ public string? ReservationId { get; set; }
+}
+
+[ProtoContract]
+public class BookReservationRequest
+{
+ [ProtoMember(1)]
+ public string? Id { get; set; }
+}
+
+[ProtoContract]
+public class BookReservationReply
+{
+ [ProtoMember(1)]
+ public bool Completed { get; set; }
+}
diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaGrpcService.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaGrpcService.cs
new file mode 100644
index 000000000..69221ebdd
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaGrpcService.cs
@@ -0,0 +1,23 @@
+using ProtoBuf.Grpc;
+using Wolverine.Grpc;
+
+namespace Wolverine.Grpc.Tests.SagaOverGrpc;
+
+///
+/// Code-first gRPC service that forwards both saga messages to the Wolverine bus with the
+/// canonical Bus.InvokeAsync<TResponse> shim — nothing here is saga-aware. Proves a
+/// message-identified saga can be started and continued over gRPC exactly like the HTTP endpoint
+/// equivalent.
+///
+public class ReservationSagaGrpcService : WolverineGrpcServiceBase, IReservationSagaService
+{
+ public ReservationSagaGrpcService(IMessageBus bus) : base(bus)
+ {
+ }
+
+ public Task Start(StartReservationRequest request, CallContext context = default)
+ => Bus.InvokeAsync(request, context.CancellationToken);
+
+ public Task Book(BookReservationRequest request, CallContext context = default)
+ => Bus.InvokeAsync(request, context.CancellationToken);
+}
diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcContracts.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcContracts.cs
new file mode 100644
index 000000000..3d4fb4edd
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcContracts.cs
@@ -0,0 +1,39 @@
+using ProtoBuf;
+using ProtoBuf.Grpc;
+using System.ServiceModel;
+
+namespace Wolverine.Grpc.Tests.SagaOverGrpc;
+
+///
+/// Code-first gRPC contract whose server implementation forwards a saga message to the
+/// Wolverine bus. Used to characterize what happens when a header-identified saga
+/// (one whose id is resolved from the envelope saga-id header, not the message body)
+/// is driven over a gRPC service hop — see for why this message
+/// deliberately carries no id member.
+///
+[ServiceContract]
+public interface ICountingSagaService
+{
+ Task Start(StartCountingRequest request, CallContext context = default);
+}
+
+///
+/// Saga start message with no id-like member — no Id, SagaId,
+/// CountingSagaId, nor [SagaIdentity]. This forces
+/// SagaChain.DetermineSagaIdMember to return null, which routes identity resolution onto
+/// the envelope saga-id header (PullSagaIdFromEnvelopeFrame) instead of the
+/// message body.
+///
+[ProtoContract]
+public class StartCountingRequest
+{
+ [ProtoMember(1)]
+ public string? Label { get; set; }
+}
+
+[ProtoContract]
+public class StartCountingReply
+{
+ [ProtoMember(1)]
+ public string? SagaId { get; set; }
+}
diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcFixture.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcFixture.cs
new file mode 100644
index 000000000..ab117f9b2
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcFixture.cs
@@ -0,0 +1,73 @@
+using Grpc.Net.Client;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using ProtoBuf.Grpc.Client;
+using ProtoBuf.Grpc.Server;
+using Xunit;
+
+namespace Wolverine.Grpc.Tests.SagaOverGrpc;
+
+///
+/// A standalone in-process host (not the shared )
+/// that wires the saga handlers used by these tests and maps their gRPC services:
+/// (header-identified, reproduces the GH-3385 gap) and
+/// (message-identified, the HTTP-parity happy path). Uses
+/// Wolverine's default in-memory saga persistence, so no database is required.
+///
+public class SagaOverGrpcFixture : IAsyncLifetime
+{
+ private WebApplication? _app;
+ public GrpcChannel? Channel { get; private set; }
+
+ ///
+ /// The server host's service provider — used by tests to resolve
+ /// InMemorySagaPersistor and assert saga persistence directly, the in-memory
+ /// equivalent of the HTTP saga test's Marten LoadAsync.
+ ///
+ public IServiceProvider Services => _app!.Services;
+
+ public async Task InitializeAsync()
+ {
+ var builder = WebApplication.CreateBuilder([]);
+ builder.WebHost.UseTestServer();
+
+ builder.Host.UseWolverine(opts =>
+ {
+ opts.ApplicationAssembly = typeof(SagaOverGrpcFixture).Assembly;
+ opts.Discovery.DisableConventionalDiscovery();
+ opts.Discovery.IncludeType(typeof(CountingSaga));
+ opts.Discovery.IncludeType(typeof(ReservationSaga));
+ });
+
+ builder.Services.AddCodeFirstGrpc();
+ builder.Services.AddWolverineGrpc();
+
+ _app = builder.Build();
+ _app.UseRouting();
+ _app.MapGrpcService();
+ _app.MapGrpcService();
+
+ await _app.StartAsync();
+
+ var handler = _app.GetTestServer().CreateHandler();
+ Channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
+ {
+ HttpHandler = handler
+ });
+ }
+
+ public async Task DisposeAsync()
+ {
+ Channel?.Dispose();
+ if (_app != null)
+ {
+ await _app.StopAsync();
+ await _app.DisposeAsync();
+ }
+ }
+
+ public ICountingSagaService CreateClient() => Channel!.CreateGrpcService();
+
+ public IReservationSagaService CreateReservationClient() => Channel!.CreateGrpcService();
+}
diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs
new file mode 100644
index 000000000..ea21a3bb4
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs
@@ -0,0 +1,72 @@
+using Grpc.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Wolverine.Persistence.Sagas;
+using Xunit;
+
+namespace Wolverine.Grpc.Tests.SagaOverGrpc;
+
+///
+/// Tests for driving a Wolverine saga over a gRPC service hop, covering both identity models.
+///
+/// Message-identified sagas (id on the request DTO) work over gRPC just like they do
+/// over HTTP — the gRPC shim forwards to the same InvokeAsync pipeline that a
+/// [WolverinePost] endpoint uses.
+/// is the gRPC parallel of
+/// Wolverine.Http.Tests.building_a_saga_and_publishing_other_messages_from_http_endpoint.
+///
+///
+/// Header-identified sagas (id from the envelope saga-id header) can't resolve
+/// their id over gRPC, because nothing carries saga-id across the hop — the same
+/// limitation HTTP endpoints have. That gap (GH-3385) is pinned by
+/// ;
+/// when the scoped diagnostic lands, flip its assertions to the actionable status/message.
+///
+///
+public class saga_over_grpc_tests : IClassFixture
+{
+ private readonly SagaOverGrpcFixture _fixture;
+
+ public saga_over_grpc_tests(SagaOverGrpcFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task can_start_and_continue_a_message_identified_saga_over_grpc()
+ {
+ var client = _fixture.CreateReservationClient();
+ var persistor = _fixture.Services.GetRequiredService();
+
+ // Start the saga over gRPC — same InvokeAsync path a WolverinePost endpoint would take.
+ var booked = await client.Start(new StartReservationRequest { ReservationId = "dinner" });
+ booked.ReservationId.ShouldBe("dinner");
+
+ // The saga was persisted by its message-supplied id, no saga-id header required.
+ var saved = persistor.Load("dinner");
+ saved.ShouldNotBeNull();
+ saved.Booked.ShouldBeFalse();
+
+ // Continue the saga over gRPC — id comes off the follow-up message.
+ var result = await client.Book(new BookReservationRequest { Id = "dinner" });
+ result.Completed.ShouldBeTrue();
+
+ // Handle(BookReservationRequest) marked the saga completed, so its state is deleted.
+ persistor.Load("dinner").ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task starting_a_header_identified_saga_over_grpc_fails_with_opaque_status_today()
+ {
+ var client = _fixture.CreateClient();
+
+ var ex = await Should.ThrowAsync(async () =>
+ await client.Start(new StartCountingRequest { Label = "first" }));
+
+ // CHARACTERIZATION of current behavior (GH-3385). saga-id is not propagated across a gRPC
+ // hop, so PullSagaIdFromEnvelopeFrame throws IndeterminateSagaStateIdException, which maps to
+ // Internal with a message that doesn't tell the developer what to do about it.
+ ex.StatusCode.ShouldBe(StatusCode.Internal);
+ ex.Status.Detail.ShouldContain("saga state id");
+ }
+}