Repository: JasperFx/wolverine (2026-07-14T11:51:39Z)

Issue:
CosmosDB persistence requires a camelCase serializer policy, and nothing says so
Found reviewing #3410, where it caused a broken sample.

Cosmos requires documents to carry a lowercase `id` property. A .NET type with a PascalCase `Id` therefore does **not** round-trip unless the `CosmosClient` is configured with:

```csharp
new CosmosClientOptions
{
    SerializerOptions = new CosmosSerializationOptions
    {
        PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
    }
}
```

Wolverine's own Cosmos test suite does exactly this (`src/Persistence/CosmosDbTests/AppFixture.cs:55-57`), which is the only reason the C# Cosmos saga tests pass. **Nothing in `docs/` mentions the requirement.**

A user who registers a `CosmosClient` without it gets, on the first saga persist:

```
400 Bad Request: The input content is invalid because the required properties - 'id; ' - are missing
```

and then — because the saga never persisted — an `UnknownSagaException` on the follow-up message. The 400 is at least loud; the saga failure that follows looks like an unrelated bug.

This bit the #3410 contributor immediately: their sample declares `Thing = { id: string }` **with a comment noting Cosmos needs a lowercase id**, then adds a saga with a PascalCase `Id` and a client with no naming policy. If the person actively working in the file can't keep it straight, the docs owe them a sentence.

## Ask

Document it on the CosmosDB persistence page: state the requirement, show the `CosmosClientOptions` snippet, and say plainly what the failure looks like if you skip it.

Worth considering as a follow-up: Wolverine could **detect** the misconfiguration at bootstrap (we can inspect the registered `CosmosClient`'s `ClientOptions.SerializerOptions`) and throw an actionable startup exception instead of letting it fail at first save — the same move we just made for the Marten daemon-mode guard in #3400. That converts a confusing runtime 400 into a clear config error.

Refs #3410.


Discussion:
> Worth considering as a follow-up: Wolverine could detect the misconfiguration at bootstrap (we can inspect the registered CosmosClient's ClientOptions.SerializerOptions) and throw an actionable startup exception instead of letting it fail at first save — the same move we just made for the Marten daemon-mode guard in https://github.com/JasperFx/wolverine/pull/3400. That converts a confusing runtime 400 into a clear config error.

this is also dealt with in #3426 



Patch:
diff --git a/docs/guide/durability/cosmosdb.md b/docs/guide/durability/cosmosdb.md
index a6b64d97d..5c677d527 100644
--- a/docs/guide/durability/cosmosdb.md
+++ b/docs/guide/durability/cosmosdb.md
@@ -15,7 +15,14 @@ var builder = Host.CreateApplicationBuilder();
 // Register CosmosClient with DI
 builder.Services.AddSingleton(new CosmosClient(
     "your-connection-string",
-    new CosmosClientOptions { /* options */ }
+    new CosmosClientOptions
+    {
+        // Required if you use CosmosDB saga persistence. See "Serializer Configuration" below
+        SerializerOptions = new CosmosSerializationOptions
+        {
+            PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
+        }
+    }
 ));
 
 builder.UseWolverine(opts =>
@@ -29,6 +36,62 @@ builder.UseWolverine(opts =>
 });
 ```
 
+## Serializer Configuration
+
+::: warning
+If you use CosmosDB **saga persistence**, the `CosmosClient` you register **must** be configured to camel
+case its property names. Wolverine refuses to start otherwise.
+:::
+
+CosmosDB rejects any document that does not carry a lowercase `id` property. The CosmosDB SDK's default
+serializer writes .NET property names verbatim, so a saga with the usual PascalCase `Id` is serialized as
+`"Id"` — and CosmosDB will not store it. Tell the client to camel case its property names:
+
+```cs
+var client = new CosmosClient("your-connection-string", new CosmosClientOptions
+{
+    SerializerOptions = new CosmosSerializationOptions
+    {
+        PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
+    }
+});
+```
+
+Skip this and the first saga persist fails with:
+
+```
+400 Bad Request: The input content is invalid because the required properties - 'id; ' - are missing
+```
+
+Worse, because the saga was never stored, every follow-up message for that saga then fails with an
+`UnknownSagaException` — which looks like an unrelated bug and sends you looking in the wrong place.
+
+To keep that from ever reaching production, Wolverine checks the registered `CosmosClient` at startup: it
+serializes a probe instance of each saga with the client's own serializer, and throws an actionable
+`InvalidOperationException` naming the offending saga types if the resulting document would have no `id`.
+Applications that use CosmosDB only for message persistence are unaffected — every document Wolverine
+writes itself already carries an explicit `id` mapping — and start regardless of the naming policy.
+
+If you would rather not change the naming policy for the whole client, map the saga's identity member onto
+the document id yourself instead:
+
+```cs
+public class OrderSaga : Saga
+{
+    // The SDK's default serializer is Newtonsoft based, so use Newtonsoft's [JsonProperty] here. A
+    // System.Text.Json [JsonPropertyName] only has an effect if you also register a System.Text.Json
+    // based CosmosSerializer on the client
+    [JsonProperty("id")]
+    public string Id { get; set; }
+
+    // ... rest of the saga
+}
+```
+
+The same rule applies to any of your own documents that Wolverine writes for you through
+[`ICosmosDbOp`](#storage-side-effects-icosmosdbop) or the [transactional middleware](#transactional-middleware):
+they need a lowercase `id` in the JSON too, whether from camel casing or an explicit mapping.
+
 ## Aspire Integration
 
 The cleanest way to integrate Wolverine with .NET Aspire for Cosmos DB is via the `Aspire.Azure.Data.Cosmos` client NuGet, which registers a `CosmosClient` in DI. Wolverine's `UseCosmosDbPersistence()` reads `CosmosClient` from DI automatically.
@@ -51,7 +114,15 @@ var builder = Host.CreateApplicationBuilder(args);
 
 // Aspire.Azure.Data.Cosmos reads the connection from configuration and
 // registers CosmosClient in DI — Wolverine picks it up automatically
-builder.AddAzureCosmosClient("cosmos");
+builder.AddAzureCosmosClient("cosmos", configureClientOptions: options =>
+{
+    // Aspire builds the client for you, so this is where saga persistence gets its
+    // camel casing. See "Serializer Configuration" above
+    options.SerializerOptions = new CosmosSerializationOptions
+    {
+        PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
+    };
+});
 
 builder.UseWolverine(opts =>
 {
@@ -94,6 +165,10 @@ and the ability to replay designated messages in the dead letter queue storage.
 The CosmosDB integration can serve as a [Wolverine Saga persistence mechanism](/guide/durability/sagas). The only limitation is that
 all saga identity values must be `string` types. The saga id is used as both the CosmosDB document id and partition key.
 
+Because it is the document id, the saga's identity member has to serialize as the lowercase `id` CosmosDB
+requires — which means the registered `CosmosClient` needs the camel case naming policy described in
+[Serializer Configuration](#serializer-configuration). Wolverine enforces this at startup.
+
 ### Optimistic Concurrency
 
 Saga writes are protected by optimistic concurrency. Wolverine captures the document's `ETag` when it reads the saga
diff --git a/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbSagaSerializationValidator.cs b/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbSagaSerializationValidator.cs
new file mode 100644
index 000000000..3eb1d8d3b
--- /dev/null
+++ b/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbSagaSerializationValidator.cs
@@ -0,0 +1,255 @@
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using JasperFx;
+using JasperFx.Core;
+using JasperFx.Core.Reflection;
+using Microsoft.Azure.Cosmos;
+using Microsoft.Extensions.Hosting;
+using Wolverine.Persistence.Sagas;
+using Wolverine.Runtime.Handlers;
+
+namespace Wolverine.CosmosDb.Internals;
+
+/// <summary>
+/// GH-3416: refuses a CosmosClient that would not write a saga's identity member as the lowercase
+/// <c>id</c> property CosmosDB demands on every document.
+///
+/// CosmosDB rejects any document without an <c>id</c> field. Wolverine's own envelope, node, and dead
+/// letter documents carry explicit <c>[JsonProperty("id")]</c>/<c>[JsonPropertyName("id")]</c> mappings,
+/// so message persistence works with any serializer — but a user's saga is their own POCO with a
+/// PascalCase <c>Id</c>, and the CosmosClient's DEFAULT serializer writes that as <c>"Id"</c>. The first
+/// saga persist then fails with "400 Bad Request: The input content is invalid because the required
+/// properties - 'id; ' - are missing", and because the saga never landed, every follow-up message for it
+/// dies with an <c>UnknownSagaException</c> that points nowhere near the real cause. The fix is a one-line
+/// <c>CosmosSerializationOptions { PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase }</c> —
+/// which is exactly what Wolverine's own Cosmos test fixture sets, and the only reason its saga suite is
+/// green. Better to say so at startup than to let the user find it through a 400 in production.
+///
+/// Checked at host start rather than inside <c>UseCosmosDbPersistence()</c> because the CosmosClient is
+/// pulled from DI — it does not exist while Wolverine is being configured, and the user may register it
+/// either side of the UseWolverine() call. By start-up both the client and the handler graph are final.
+/// The check only looks at saga types this Cosmos provider actually persists, so an application that uses
+/// CosmosDB purely for envelope storage still boots.
+/// </summary>
+public class CosmosDbSagaSerializationValidator : IHostedService
+{
+    private readonly CosmosClient _client;
+    private readonly IServiceContainer _container;
+    private readonly HandlerGraph _handlers;
+    private readonly WolverineOptions _options;
+
+    public CosmosDbSagaSerializationValidator(CosmosClient client, HandlerGraph handlers, WolverineOptions options,
+        IServiceContainer container)
+    {
+        _client = client;
+        _handlers = handlers;
+        _options = options;
+        _container = container;
+    }
+
+    public Task StartAsync(CancellationToken cancellationToken)
+    {
+        var clientOptions = _client.ClientOptions;
+
+        // The SDK wraps even its own default serializer, so this is only ever null if the client was
+        // built in some way we do not understand -- in which case there is nothing to assert
+        var serializer = clientOptions.Serializer;
+        if (serializer == null)
+        {
+            return Task.CompletedTask;
+        }
+
+        var offenders = cosmosSagaTypes()
+            .Where(x => !CosmosSagaIdentity.WillWriteCosmosId(x, serializer))
+            .ToArray();
+
+        if (offenders.Length != 0)
+        {
+            throw new InvalidOperationException(CosmosSagaIdentity.ToFailureMessage(offenders, serializer,
+                clientOptions.SerializerOptions?.PropertyNamingPolicy));
+        }
+
+        return Task.CompletedTask;
+    }
+
+    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+    /// <summary>
+    /// The saga state types that this Cosmos provider is the one persisting. Resolved through the same
+    /// GetPersistenceProviders() lookup the saga code generation itself uses, so a mixed-persistence
+    /// application is judged on the storage its sagas actually get, not on the packages it references.
+    /// </summary>
+    private IEnumerable<Type> cosmosSagaTypes()
+    {
+        var rules = _options.CodeGeneration;
+
+        return _handlers.Chains
+            .OfType<SagaChain>()
+            .Where(chain => rules.GetPersistenceProviders(chain, _container) is CosmosDbPersistenceFrameProvider)
+            .Select(chain => chain.SagaType)
+            .Distinct();
+    }
+}
+
+/// <summary>
+/// Answers "what JSON property will this saga's identity member actually be written under?" by handing a
+/// probe instance of the saga to the CosmosClient's own serializer and reading the JSON back. Asking the
+/// serializer beats reasoning about it: naming policies, [JsonProperty]/[JsonPropertyName] mappings,
+/// contract resolvers and custom CosmosSerializer implementations all get the same honest answer, and one
+/// that cannot drift from what the client will really send to CosmosDB.
+/// </summary>
+public static class CosmosSagaIdentity
+{
+    /// <summary>
+    ///     The document id property CosmosDB requires. Lowercase, always.
+    /// </summary>
+    public const string CosmosIdProperty = "id";
+
+    // Written into the identity member so the property carrying it can be picked back out of the JSON,
+    // whatever the serializer decided to call it
+    private const string Probe = "wolverine-identity-probe";
+
+    /// <summary>
+    /// Will a document written for this saga carry the "id" CosmosDB requires? Anything we cannot work out
+    /// -- a saga we cannot construct, a serializer that will not take it -- answers true: a startup guard
+    /// that cries wolf on an application that works is worse than no guard at all.
+    /// </summary>
+    public static bool WillWriteCosmosId(Type sagaType, CosmosSerializer serializer)
+    {
+        return DetermineJsonPropertyName(sagaType, serializer) is null or CosmosIdProperty;
+    }
+
+    /// <summary>
+    /// The JSON property name the saga's identity member is serialized under, or null when that cannot be
+    /// determined.
+    /// </summary>
+    public static string? DetermineJsonPropertyName(Type sagaType, CosmosSerializer serializer)
+    {
+        var member = SagaChain.DetermineSagaIdMember(sagaType, sagaType);
+        if (member == null)
+        {
+            return null;
+        }
+
+        var saga = tryStamp(sagaType, member, out var stamped);
+        if (saga == null)
+        {
+            return null;
+        }
+
+        using var document = trySerialize(saga, serializer);
+        if (document == null || document.RootElement.ValueKind != JsonValueKind.Object)
+        {
+            return null;
+        }
+
+        if (stamped)
+        {
+            // The property holding the probe IS the identity member, under whatever name it was written
+            foreach (var property in document.RootElement.EnumerateObject())
+            {
+                if (property.Value.ValueKind == JsonValueKind.String && property.Value.GetString() == Probe)
+                {
+                    return property.Name;
+                }
+            }
+        }
+
+        // The identity member would not take the probe (no setter, not a string). Fall back to asking
+        // whether the document has an id at all
+        return document.RootElement.TryGetProperty(CosmosIdProperty, out _) ? CosmosIdProperty : member.Name;
+    }
+
+    public static string ToFailureMessage(IReadOnlyList<Type> offenders, CosmosSerializer serializer,
+        CosmosPropertyNamingPolicy? policy)
+    {
+        var message = new StringBuilder();
+
+        message.Append(
+            "CosmosDB rejects any document without a lowercase 'id' property, and the registered CosmosClient will not write one for ");
+        message.Append(offenders.Count == 1 ? "this saga: " : "these sagas: ");
+        message.Append(offenders.Select(x => describe(x, serializer)).Join(", "));
+        message.Append(". ");
+
+        message.Append(
+            "The first saga persist would fail with \"400 Bad Request: The input content is invalid because the required properties - 'id; ' - are missing\", and every later message for that saga would then fail with an UnknownSagaException because the saga was never stored. ");
+
+        if (policy != CosmosPropertyNamingPolicy.CamelCase)
+        {
+            message.Append(
+                "Configure the CosmosClient to camel case its property names -- new CosmosClient(connectionString, new CosmosClientOptions { SerializerOptions = new CosmosSerializationOptions { PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase } }) -- so that a saga's Id is written as 'id'. ");
+        }
+
+        message.Append(
+            "Alternatively, map the saga's identity member onto the document id yourself with Newtonsoft's [JsonProperty(\"id\")] -- note that the CosmosDB SDK's default serializer is Newtonsoft based, so a System.Text.Json [JsonPropertyName(\"id\")] does nothing unless you also register a System.Text.Json based CosmosSerializer.");
+
+        return message.ToString();
+    }
+
+    private static string describe(Type sagaType, CosmosSerializer serializer)
+    {
+        var member = SagaChain.DetermineSagaIdMember(sagaType, sagaType);
+        return
+            $"{sagaType.FullNameInCode()} (identity member '{member?.Name}' is serialized as '{DetermineJsonPropertyName(sagaType, serializer)}')";
+    }
+
+    /// <summary>
+    /// A saga instance with the probe written into its identity member. <paramref name="stamped"/> is false
+    /// when the member would not take it, which is fine -- the document is still worth serializing to see
+    /// whether it has an id.
+    /// </summary>
+    private static object? tryStamp(Type sagaType, MemberInfo member, out bool stamped)
+    {
+        stamped = false;
+
+        object saga;
+        try
+        {
+            saga = Activator.CreateInstance(sagaType)!;
+        }
+        catch (Exception)
+        {
+            // No usable default constructor. Wolverine's CreateNewSagaFrame already refuses that saga with a
+            // better message than anything this guard could add
+            return null;
+        }
+
+        try
+        {
+            switch (member)
+            {
+                case PropertyInfo { CanWrite: true } property when property.PropertyType == typeof(string):
+                    property.SetValue(saga, Probe);
+                    stamped = true;
+                    break;
+
+                case FieldInfo field when field.FieldType == typeof(string):
+                    field.SetValue(saga, Probe);
+                    stamped = true;
+                    break;
+            }
+        }
+        catch (Exception)
+        {
+            stamped = false;
+        }
+
+        return saga;
+    }
+
+    private static JsonDocument? trySerialize(object saga, CosmosSerializer serializer)
+    {
+        try
+        {
+            using var stream = serializer.ToStream(saga);
+            return JsonDocument.Parse(stream);
+        }
+        catch (Exception)
+        {
+            // A custom CosmosSerializer is free to reject a type it was never meant to see. We are in no
+            // position to second guess it
+            return null;
+        }
+    }
+}
diff --git a/src/Persistence/Wolverine.CosmosDb/WolverineCosmosDbExtensions.cs b/src/Persistence/Wolverine.CosmosDb/WolverineCosmosDbExtensions.cs
index 687a99f38..752cae758 100644
--- a/src/Persistence/Wolverine.CosmosDb/WolverineCosmosDbExtensions.cs
+++ b/src/Persistence/Wolverine.CosmosDb/WolverineCosmosDbExtensions.cs
@@ -1,5 +1,6 @@
 using Microsoft.Azure.Cosmos;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
 using Wolverine.CosmosDb.Internals;
 using Wolverine.Persistence.Durability;
 using Wolverine.Persistence.Sagas;
@@ -32,6 +33,12 @@ public static WolverineOptions UseCosmosDbPersistence(this WolverineOptions opti
             return client.GetDatabase(databaseName).GetContainer(DocumentTypes.ContainerName);
         });
 
+        // GH-3416 -- CosmosDB requires a lowercase "id" on every document. A saga's PascalCase Id only
+        // serializes that way if the CosmosClient is set to camel case its property names, and nothing
+        // else in the system says so until the first saga write comes back a 400. Refuse at host start,
+        // where the client is resolvable and the saga types are known.
+        options.Services.AddSingleton<IHostedService, CosmosDbSagaSerializationValidator>();
+
         options.CodeGeneration.InsertFirstPersistenceStrategy<CosmosDbPersistenceFrameProvider>();
         options.CodeGeneration.ReferenceAssembly(typeof(WolverineCosmosDbExtensions).Assembly);
         return options;


Test patch:
diff --git a/src/Persistence/CosmosDbTests/saga_serialization_validation.cs b/src/Persistence/CosmosDbTests/saga_serialization_validation.cs
new file mode 100644
index 000000000..a31cb077b
--- /dev/null
+++ b/src/Persistence/CosmosDbTests/saga_serialization_validation.cs
@@ -0,0 +1,237 @@
+using System.Text.Json.Serialization;
+using JasperFx;
+using Microsoft.Azure.Cosmos;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine;
+using Wolverine.Attributes;
+using Wolverine.CosmosDb.Internals;
+using Wolverine.Persistence.Sagas;
+using Wolverine.Runtime.Handlers;
+using JsonPropertyAttribute = Newtonsoft.Json.JsonPropertyAttribute;
+
+namespace CosmosDbTests;
+
+/// <summary>
+/// GH-3416. CosmosDB requires a lowercase "id" on every document, so a saga with a PascalCase Id only
+/// round trips if the CosmosClient was told to camel case its property names. These tests pin the startup
+/// guard that says so, and they deliberately need no emulator: a CosmosClient does no I/O until it is asked
+/// to, and the guard only serializes a probe saga with the client's own serializer.
+/// </summary>
+public class saga_serialization_validation
+{
+    // The well known CosmosDB emulator key. Never connected to -- CosmosClient does no I/O in its constructor
+    private const string ConnectionString =
+        "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
+
+    private static CosmosClient clientWith(CosmosPropertyNamingPolicy? policy)
+    {
+        var options = new CosmosClientOptions();
+        if (policy.HasValue)
+        {
+            options.SerializerOptions = new CosmosSerializationOptions { PropertyNamingPolicy = policy.Value };
+        }
+
+        return new CosmosClient(ConnectionString, options);
+    }
+
+    private static CosmosSerializer serializerFor(CosmosPropertyNamingPolicy? policy)
+    {
+        return clientWith(policy).ClientOptions.Serializer;
+    }
+
+    [Fact]
+    public void pascal_case_id_does_not_write_a_cosmos_id_without_camel_casing()
+    {
+        var serializer = serializerFor(null);
+
+        CosmosSagaIdentity.DetermineJsonPropertyName(typeof(PascalCaseSaga), serializer).ShouldBe("Id");
+        CosmosSagaIdentity.WillWriteCosmosId(typeof(PascalCaseSaga), serializer).ShouldBeFalse();
+    }
+
+    [Fact]
+    public void pascal_case_id_is_fine_when_the_client_camel_cases()
+    {
+        var serializer = serializerFor(CosmosPropertyNamingPolicy.CamelCase);
+
+        CosmosSagaIdentity.DetermineJsonPropertyName(typeof(PascalCaseSaga), serializer).ShouldBe("id");
+        CosmosSagaIdentity.WillWriteCosmosId(typeof(PascalCaseSaga), serializer).ShouldBeTrue();
+    }
+
+    [Fact]
+    public void an_explicit_newtonsoft_mapping_is_enough_on_its_own()
+    {
+        CosmosSagaIdentity.WillWriteCosmosId(typeof(NewtonsoftMappedSaga), serializerFor(null)).ShouldBeTrue();
+    }
+
+    /// <summary>
+    /// The SDK's default serializer is Newtonsoft based, so a System.Text.Json mapping does NOT rescue a
+    /// saga that would otherwise be written as "Id" -- exactly the sort of thing a user is better off
+    /// hearing at startup than inferring from a 400
+    /// </summary>
+    [Fact]
+    public void a_system_text_json_mapping_does_not_help_the_default_newtonsoft_serializer()
+    {
+        CosmosSagaIdentity.DetermineJsonPropertyName(typeof(SystemTextJsonMappedSaga), serializerFor(null))
+            .ShouldBe("Id");
+    }
+
+    /// <summary>
+    /// Camel casing does not save a saga whose identity member is not named "Id" at all -- "PickupSagaId"
+    /// camel cases to "pickupSagaId", and the document still has no id
+    /// </summary>
+    [Fact]
+    public void a_differently_named_identity_member_still_has_to_be_mapped()
+    {
+        var serializer = serializerFor(CosmosPropertyNamingPolicy.CamelCase);
+
+        CosmosSagaIdentity.DetermineJsonPropertyName(typeof(PickupSaga), serializer).ShouldBe("pickupSagaId");
+        CosmosSagaIdentity.WillWriteCosmosId(typeof(PickupSaga), serializer).ShouldBeFalse();
+    }
+
+    [Fact]
+    public async Task throws_at_startup_when_the_client_will_not_write_a_document_id()
+    {
+        using var host = await hostWith(typeof(PascalCaseSaga));
+
+        var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
+            validatorFor(host, clientWith(null)).StartAsync(CancellationToken.None));
+
+        ex.Message.ShouldContain(nameof(PascalCaseSaga));
+        ex.Message.ShouldContain("CosmosPropertyNamingPolicy.CamelCase");
+    }
+
+    [Fact]
+    public async Task passes_when_the_client_camel_cases_its_property_names()
+    {
+        using var host = await hostWith(typeof(PascalCaseSaga));
+
+        await Should.NotThrowAsync(() =>
+            validatorFor(host, clientWith(CosmosPropertyNamingPolicy.CamelCase)).StartAsync(CancellationToken.None));
+    }
+
+    /// <summary>
+    /// A saga that maps its own identity member to "id" is already correct under any naming policy, and
+    /// refusing to start that application would be a false alarm
+    /// </summary>
+    [Fact]
+    public async Task passes_when_the_saga_maps_its_own_document_id()
+    {
+        using var host = await hostWith(typeof(NewtonsoftMappedSaga));
+
+        await Should.NotThrowAsync(() =>
+            validatorFor(host, clientWith(null)).StartAsync(CancellationToken.None));
+    }
+
+    /// <summary>
+    /// An application using CosmosDB purely for envelope storage has nothing to fix -- every document
+    /// Wolverine writes itself already carries an explicit "id" mapping -- so it has to keep booting
+    /// </summary>
+    [Fact]
+    public async Task passes_when_there_are_no_sagas_at_all()
+    {
+        using var host = await hostWith(null);
+
+        await Should.NotThrowAsync(() =>
+            validatorFor(host, clientWith(null)).StartAsync(CancellationToken.None));
+    }
+
+    /// <summary>
+    /// A custom CosmosSerializer is entitled to reject a type it was never meant to see, and the guard is in
+    /// no position to second guess it
+    /// </summary>
+    [Fact]
+    public async Task stands_down_for_a_serializer_that_will_not_take_the_saga()
+    {
+        using var host = await hostWith(typeof(PascalCaseSaga));
+
+        var client = new CosmosClient(ConnectionString,
+            new CosmosClientOptions { Serializer = new FakeCosmosSerializer() });
+
+        await Should.NotThrowAsync(() => validatorFor(host, client).StartAsync(CancellationToken.None));
+    }
+
+    private static CosmosDbSagaSerializationValidator validatorFor(IHost host, CosmosClient client)
+    {
+        return new CosmosDbSagaSerializationValidator(
+            client,
+            host.Services.GetRequiredService<HandlerGraph>(),
+            host.Services.GetRequiredService<WolverineOptions>(),
+            host.Services.GetRequiredService<IServiceContainer>());
+    }
+
+    /// <summary>
+    /// Stands in for a real UseCosmosDbPersistence() application: same persistence strategy and the same
+    /// registered Container, but no message store -- so the handler graph the validator reads is the one a
+    /// Cosmos application really compiles, without an emulator having to be up to boot it
+    /// </summary>
+    private static Task<IHost> hostWith(Type? sagaType)
+    {
+        return Host.CreateDefaultBuilder()
+            .UseWolverine(opts =>
+            {
+                var client = clientWith(CosmosPropertyNamingPolicy.CamelCase);
+                opts.Services.AddSingleton(client.GetDatabase("wolverine").GetContainer(DocumentTypes.ContainerName));
+
+                opts.CodeGeneration.InsertFirstPersistenceStrategy<CosmosDbPersistenceFrameProvider>();
+                opts.CodeGeneration.ReferenceAssembly(typeof(CosmosDbPersistenceFrameProvider).Assembly);
+
+                opts.Discovery.DisableConventionalDiscovery();
+                if (sagaType != null)
+                {
+                    opts.Discovery.IncludeType(sagaType);
+                }
+            })
+            .StartAsync();
+    }
+}
+
+// These sagas exist only to be fed to the guard. [WolverineIgnore] keeps them out of the conventional
+// discovery that the emulator-backed tests in this assembly run through IncludeAssembly() -- PickupSaga in
+// particular is deliberately mis-mapped, and the guard would rightly refuse to start those hosts. An
+// explicit Discovery.IncludeType() still picks them up here.
+public record StartWorkflow(string Id);
+
+public record StartPickup(string PickupSagaId);
+
+[WolverineIgnore]
+public class PascalCaseSaga : Saga
+{
+    public string Id { get; set; } = string.Empty;
+
+    public static PascalCaseSaga Start(StartWorkflow command) => new() { Id = command.Id };
+}
+
+[WolverineIgnore]
+public class NewtonsoftMappedSaga : Saga
+{
+    [JsonProperty("id")]
+    public string Id { get; set; } = string.Empty;
+
+    public static NewtonsoftMappedSaga Start(StartWorkflow command) => new() { Id = command.Id };
+}
+
+[WolverineIgnore]
+public class SystemTextJsonMappedSaga : Saga
+{
+    [JsonPropertyName("id")]
+    public string Id { get; set; } = string.Empty;
+
+    public static SystemTextJsonMappedSaga Start(StartWorkflow command) => new() { Id = command.Id };
+}
+
+[WolverineIgnore]
+public class PickupSaga : Saga
+{
+    public string PickupSagaId { get; set; } = string.Empty;
+
+    public static PickupSaga Start(StartPickup command) => new() { PickupSagaId = command.PickupSagaId };
+}
+
+internal class FakeCosmosSerializer : CosmosSerializer
+{
+    public override T FromStream<T>(Stream stream) => throw new NotSupportedException();
+
+    public override Stream ToStream<T>(T input) => throw new NotSupportedException();
+}