diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Modular.Tests/Examples.EventHandling.Outbox.Modular.Tests.csproj b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular.Tests/Examples.EventHandling.Outbox.Modular.Tests.csproj
new file mode 100644
index 00000000..7803c95d
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular.Tests/Examples.EventHandling.Outbox.Modular.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Modular.Tests/ModularMultiDataStoreOutboxTests.cs b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular.Tests/ModularMultiDataStoreOutboxTests.cs
new file mode 100644
index 00000000..a70beee3
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular.Tests/ModularMultiDataStoreOutboxTests.cs
@@ -0,0 +1,102 @@
+using Examples.EventHandling.Outbox.Modular.Billing;
+using Examples.EventHandling.Outbox.Modular.Ordering;
+using Examples.EventHandling.Outbox.Modular.Shipping;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using RCommon;
+using RCommon.Entities;
+using RCommon.Persistence.Crud;
+using RCommon.Persistence.Outbox;
+using RCommon.Persistence.Transactions;
+using Xunit;
+
+namespace Examples.EventHandling.Outbox.Modular.Tests;
+
+///
+/// End-to-end proof of the modular multi-datastore outbox example, and the regression guard for the
+/// customer's scenario (3 bounded-context modules, each its own datastore + native outbox, composed at
+/// one root). Every datastore's durable event must persist to its OWN outbox and nowhere else. Before
+/// 3.2.1 this composition silently dropped durable events for datastores whose module was registered
+/// after a different module's WithPersistence call. Runs on the EF Core InMemory provider (fast lane).
+///
+public class ModularMultiDataStoreOutboxTests
+{
+ private static ServiceProvider BuildProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ services.AddRCommon()
+ .WithSimpleGuidGenerator()
+ .WithUnitOfWork(uow => { })
+ // Compose modules NOT-primary-first, to prove the fixed registration is order-independent.
+ .AddBillingModule(Guid.NewGuid().ToString())
+ .AddShippingModule(Guid.NewGuid().ToString())
+ .AddOrderingModule(Guid.NewGuid().ToString());
+
+ return services.BuildServiceProvider(validateScopes: true);
+ }
+
+ [Fact]
+ public void OutboxTracker_IsAuthoritative_UnderThreeModuleComposition()
+ {
+ using var provider = BuildProvider();
+ using var scope = provider.CreateScope();
+
+ scope.ServiceProvider.GetRequiredService()
+ .Should().BeOfType(
+ "the outbox tracker must win across a three-module composition regardless of order");
+ }
+
+ [Fact]
+ public async Task EachModule_PersistsItsDurableEvent_ToItsOwnOutboxOnly()
+ {
+ using var provider = BuildProvider();
+
+ using (var scope = provider.CreateScope())
+ {
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ }
+
+ using (var scope = provider.CreateScope())
+ {
+ var sp = scope.ServiceProvider;
+ var uowFactory = sp.GetRequiredService();
+
+ var orders = sp.GetRequiredService>();
+ orders.DataStoreName = "Ordering";
+ var invoices = sp.GetRequiredService>();
+ invoices.DataStoreName = "Billing";
+ var shipments = sp.GetRequiredService>();
+ shipments.DataStoreName = "Shipping";
+
+ var order = new Order { CustomerName = "Ada Lovelace", Total = 249.99m };
+ order.Place();
+ var invoice = new Invoice { CustomerName = "Ada Lovelace", Amount = 249.99m };
+ invoice.Raise();
+ var shipment = new Shipment { Destination = "London", TrackingNumber = "TRK-1" };
+ shipment.Dispatch();
+
+ using var uow = uowFactory.Create();
+ await orders.AddAsync(order);
+ await invoices.AddAsync(invoice);
+ await shipments.AddAsync(shipment);
+ await uow.CommitAsync();
+ }
+
+ using (var scope = provider.CreateScope())
+ {
+ var sp = scope.ServiceProvider;
+ var orderingRows = await sp.GetRequiredService().Set().AsNoTracking().CountAsync();
+ var billingRows = await sp.GetRequiredService().Set().AsNoTracking().CountAsync();
+ var shippingRows = await sp.GetRequiredService().Set().AsNoTracking().CountAsync();
+
+ orderingRows.Should().Be(1, "the Ordering module's durable event must persist to the Ordering outbox");
+ billingRows.Should().Be(1, "the Billing module's durable event must persist to the Billing outbox");
+ shippingRows.Should().Be(1, "the Shipping module's durable event must persist to the Shipping outbox");
+ }
+ }
+}
diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Billing/BillingModule.cs b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Billing/BillingModule.cs
new file mode 100644
index 00000000..9a7a942d
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Billing/BillingModule.cs
@@ -0,0 +1,81 @@
+using Microsoft.EntityFrameworkCore;
+using RCommon;
+using RCommon.Entities;
+using RCommon.EventHandling;
+using RCommon.EventHandling.Subscribers;
+using RCommon.Persistence.EFCore;
+using RCommon.Persistence.EFCore.Outbox;
+using RCommon.Persistence.Outbox;
+
+namespace Examples.EventHandling.Outbox.Modular.Billing;
+
+// ---------------------------------------------------------------------------------------------------
+// Billing bounded context — its own datastore ("Billing") with its own transactional outbox.
+// ---------------------------------------------------------------------------------------------------
+
+public sealed class InvoiceRaisedEvent : IDomainEvent
+{
+ public InvoiceRaisedEvent(Guid invoiceId, decimal amount)
+ {
+ InvoiceId = invoiceId;
+ Amount = amount;
+ }
+
+ public Guid EventId { get; } = Guid.NewGuid();
+ public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
+ public Guid InvoiceId { get; }
+ public decimal Amount { get; }
+}
+
+public sealed class Invoice : AggregateRoot
+{
+ public Invoice() : base(Guid.NewGuid()) { }
+ public string CustomerName { get; set; } = string.Empty;
+ public decimal Amount { get; set; }
+
+ public void Raise() => AddDomainEvent(new InvoiceRaisedEvent(Id, Amount));
+}
+
+public sealed class InvoiceRaisedEventHandler : ISubscriber
+{
+ public Task HandleAsync(InvoiceRaisedEvent @event, CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine($" [billing] invoice {@event.InvoiceId} raised for {@event.Amount:C}");
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class BillingDbContext : RCommonDbContext
+{
+ public BillingDbContext(DbContextOptions options) : base(options) { }
+ public DbSet Invoices => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+ modelBuilder.AddOutboxMessages();
+ }
+}
+
+public static class BillingModule
+{
+ ///
+ /// Registers the Billing bounded context: its DbContext + datastore, its transactional outbox, and
+ /// its event route. Note there is no SetDefaultDataStore here — a non-primary module does not
+ /// touch the default; it just contributes its own datastore.
+ ///
+ public static IRCommonBuilder AddBillingModule(this IRCommonBuilder rcommon, string database)
+ {
+ return rcommon
+ .WithPersistence(ef =>
+ {
+ ef.AddDbContext("Billing", o => o.UseInMemoryDatabase(database));
+ ef.AddOutbox(dataStoreName: "Billing");
+ })
+ .WithEventHandling(events =>
+ {
+ events.AddSubscriber();
+ events.Publish().UseOutbox("Billing");
+ });
+ }
+}
diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Examples.EventHandling.Outbox.Modular.csproj b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Examples.EventHandling.Outbox.Modular.csproj
new file mode 100644
index 00000000..62439393
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Examples.EventHandling.Outbox.Modular.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Ordering/OrderingModule.cs b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Ordering/OrderingModule.cs
new file mode 100644
index 00000000..a9763925
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Ordering/OrderingModule.cs
@@ -0,0 +1,90 @@
+using Microsoft.EntityFrameworkCore;
+using RCommon;
+using RCommon.Entities;
+using RCommon.EventHandling;
+using RCommon.EventHandling.Subscribers;
+using RCommon.Persistence.EFCore;
+using RCommon.Persistence.EFCore.Outbox;
+using RCommon.Persistence.Outbox;
+
+namespace Examples.EventHandling.Outbox.Modular.Ordering;
+
+// ---------------------------------------------------------------------------------------------------
+// Ordering bounded context — its own datastore ("Ordering") with its own transactional outbox.
+// Everything this context needs is self-contained in this file, including its RCommon registration.
+// ---------------------------------------------------------------------------------------------------
+
+public sealed class OrderPlacedEvent : IDomainEvent
+{
+ public OrderPlacedEvent(Guid orderId, decimal total)
+ {
+ OrderId = orderId;
+ Total = total;
+ }
+
+ public Guid EventId { get; } = Guid.NewGuid();
+ public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
+ public Guid OrderId { get; }
+ public decimal Total { get; }
+}
+
+public sealed class Order : AggregateRoot
+{
+ public Order() : base(Guid.NewGuid()) { }
+ public string CustomerName { get; set; } = string.Empty;
+ public decimal Total { get; set; }
+
+ public void Place() => AddDomainEvent(new OrderPlacedEvent(Id, Total));
+}
+
+public sealed class OrderPlacedEventHandler : ISubscriber
+{
+ public Task HandleAsync(OrderPlacedEvent @event, CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine($" [ordering] order {@event.OrderId} placed for {@event.Total:C}");
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class OrderingDbContext : RCommonDbContext
+{
+ public OrderingDbContext(DbContextOptions options) : base(options) { }
+ public DbSet Orders => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+ modelBuilder.AddOutboxMessages(); // maps this datastore's __OutboxMessages table
+ }
+}
+
+public static class OrderingModule
+{
+ ///
+ /// Registers the Ordering bounded context: its DbContext + datastore, its transactional outbox, and
+ /// its event route. Each module owns exactly one datastore and calls WithPersistence once —
+ /// the composition root simply chains the modules together (see Program).
+ ///
+ public static IRCommonBuilder AddOrderingModule(this IRCommonBuilder rcommon, string database)
+ {
+ return rcommon
+ .WithPersistence(ef =>
+ {
+ ef.AddDbContext("Ordering", o => o.UseInMemoryDatabase(database));
+
+ // Ordering is the application's primary context, so it designates the default datastore.
+ // With more than one datastore registered, exactly one module (or the root) must set this;
+ // it is not inferred. The outbox poller uses it as its fallback datastore name.
+ ef.SetDefaultDataStore(ds => ds.DefaultDataStoreName = "Ordering");
+
+ // Native RCommon outbox for this datastore (producer + processor).
+ ef.AddOutbox(dataStoreName: "Ordering");
+ })
+ .WithEventHandling(events =>
+ {
+ events.AddSubscriber();
+ // Durability is opt-in per route: publish this context's event durably to its own outbox.
+ events.Publish().UseOutbox("Ordering");
+ });
+ }
+}
diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Program.cs b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Program.cs
new file mode 100644
index 00000000..64ea870f
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Program.cs
@@ -0,0 +1,99 @@
+using Examples.EventHandling.Outbox.Modular.Billing;
+using Examples.EventHandling.Outbox.Modular.Ordering;
+using Examples.EventHandling.Outbox.Modular.Shipping;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using RCommon;
+using RCommon.Persistence.Crud;
+using RCommon.Persistence.Outbox;
+using RCommon.Persistence.Transactions;
+
+// ---------------------------------------------------------------------------------------------------
+// MODULAR, MULTI-DATASTORE TRANSACTIONAL OUTBOX
+//
+// This is the shape a real modular application uses: several independent bounded contexts, each owning
+// its own datastore and its own transactional outbox, composed together at a single root. Each module
+// calls WithPersistence/WithEventHandling for ITSELF; no module knows about the others.
+//
+// This composition (multiple WithPersistence calls across modules, only some of which configure an
+// outbox) is exactly the shape that silently dropped durable events before 3.2.1 (see the changelog
+// and Examples.EventHandling.Outbox.Tests/ReproOutboxModularDiTests). As of 3.2.1 the outbox tracker is
+// registered authoritatively in the shared outbox core, so every datastore's durable events persist
+// regardless of the order in which the modules are composed.
+// ---------------------------------------------------------------------------------------------------
+
+var ordersDb = Guid.NewGuid().ToString();
+var billingDb = Guid.NewGuid().ToString();
+var shippingDb = Guid.NewGuid().ToString();
+
+var host = Host.CreateDefaultBuilder(args)
+ .ConfigureLogging(logging => logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning))
+ .ConfigureServices(services =>
+ {
+ services.AddRCommon()
+ .WithSimpleGuidGenerator() // the outbox router stamps each row's Id
+ .WithUnitOfWork(uow => { })
+ // Compose the modules. Order is intentionally NOT Ordering-first to demonstrate that the
+ // fixed registration is order-independent.
+ .AddBillingModule(billingDb)
+ .AddShippingModule(shippingDb)
+ .AddOrderingModule(ordersDb);
+ })
+ .Build();
+
+Console.WriteLine("Modular multi-datastore outbox example starting");
+
+// Create each datastore's schema (including its __OutboxMessages table).
+using (var scope = host.Services.CreateScope())
+{
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+}
+
+// Start the host so the single OutboxProcessingService (which drains every registered datastore) runs.
+await host.StartAsync();
+
+// Commit one aggregate per bounded context. Each raises a durable domain event that must land in ITS
+// OWN datastore's outbox — never another's.
+using (var scope = host.Services.CreateScope())
+{
+ var sp = scope.ServiceProvider;
+ var uowFactory = sp.GetRequiredService();
+
+ var orders = sp.GetRequiredService>();
+ orders.DataStoreName = "Ordering";
+ var invoices = sp.GetRequiredService>();
+ invoices.DataStoreName = "Billing";
+ var shipments = sp.GetRequiredService>();
+ shipments.DataStoreName = "Shipping";
+
+ var order = new Order { CustomerName = "Ada Lovelace", Total = 249.99m };
+ order.Place();
+ var invoice = new Invoice { CustomerName = "Ada Lovelace", Amount = 249.99m };
+ invoice.Raise();
+ var shipment = new Shipment { Destination = "London", TrackingNumber = "TRK-1" };
+ shipment.Dispatch();
+
+ using var uow = uowFactory.Create();
+ await orders.AddAsync(order);
+ await invoices.AddAsync(invoice);
+ await shipments.AddAsync(shipment);
+ await uow.CommitAsync();
+}
+
+// Show each datastore's outbox row count — all three persisted, none leaked into another datastore.
+using (var scope = host.Services.CreateScope())
+{
+ var sp = scope.ServiceProvider;
+ var orderingRows = await sp.GetRequiredService().Set().AsNoTracking().CountAsync();
+ var billingRows = await sp.GetRequiredService().Set().AsNoTracking().CountAsync();
+ var shippingRows = await sp.GetRequiredService().Set().AsNoTracking().CountAsync();
+
+ Console.WriteLine($"Outbox rows -> Ordering: {orderingRows}, Billing: {billingRows}, Shipping: {shippingRows}");
+}
+
+await host.StopAsync();
+Console.WriteLine("Modular multi-datastore outbox example complete");
diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Shipping/ShippingModule.cs b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Shipping/ShippingModule.cs
new file mode 100644
index 00000000..c476de11
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Modular/Shipping/ShippingModule.cs
@@ -0,0 +1,77 @@
+using Microsoft.EntityFrameworkCore;
+using RCommon;
+using RCommon.Entities;
+using RCommon.EventHandling;
+using RCommon.EventHandling.Subscribers;
+using RCommon.Persistence.EFCore;
+using RCommon.Persistence.EFCore.Outbox;
+using RCommon.Persistence.Outbox;
+
+namespace Examples.EventHandling.Outbox.Modular.Shipping;
+
+// ---------------------------------------------------------------------------------------------------
+// Shipping bounded context — its own datastore ("Shipping") with its own transactional outbox. A third
+// datastore makes the point that the pattern scales to N modules with no cross-module coordination.
+// ---------------------------------------------------------------------------------------------------
+
+public sealed class ShipmentDispatchedEvent : IDomainEvent
+{
+ public ShipmentDispatchedEvent(Guid shipmentId, string trackingNumber)
+ {
+ ShipmentId = shipmentId;
+ TrackingNumber = trackingNumber;
+ }
+
+ public Guid EventId { get; } = Guid.NewGuid();
+ public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
+ public Guid ShipmentId { get; }
+ public string TrackingNumber { get; } = string.Empty;
+}
+
+public sealed class Shipment : AggregateRoot
+{
+ public Shipment() : base(Guid.NewGuid()) { }
+ public string Destination { get; set; } = string.Empty;
+ public string TrackingNumber { get; set; } = string.Empty;
+
+ public void Dispatch() => AddDomainEvent(new ShipmentDispatchedEvent(Id, TrackingNumber));
+}
+
+public sealed class ShipmentDispatchedEventHandler : ISubscriber
+{
+ public Task HandleAsync(ShipmentDispatchedEvent @event, CancellationToken cancellationToken = default)
+ {
+ Console.WriteLine($" [shipping] shipment {@event.ShipmentId} dispatched ({@event.TrackingNumber})");
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class ShippingDbContext : RCommonDbContext
+{
+ public ShippingDbContext(DbContextOptions options) : base(options) { }
+ public DbSet Shipments => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+ modelBuilder.AddOutboxMessages();
+ }
+}
+
+public static class ShippingModule
+{
+ public static IRCommonBuilder AddShippingModule(this IRCommonBuilder rcommon, string database)
+ {
+ return rcommon
+ .WithPersistence(ef =>
+ {
+ ef.AddDbContext("Shipping", o => o.UseInMemoryDatabase(database));
+ ef.AddOutbox(dataStoreName: "Shipping");
+ })
+ .WithEventHandling(events =>
+ {
+ events.AddSubscriber();
+ events.Publish().UseOutbox("Shipping");
+ });
+ }
+}
diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Tests/ProcessorOnlyHostDiTests.cs b/Examples/EventHandling/Examples.EventHandling.Outbox.Tests/ProcessorOnlyHostDiTests.cs
new file mode 100644
index 00000000..0c45de43
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Tests/ProcessorOnlyHostDiTests.cs
@@ -0,0 +1,99 @@
+using Examples.EventHandling.Outbox;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using RCommon;
+using RCommon.Entities;
+using RCommon.EventHandling;
+using RCommon.Persistence.Crud;
+using RCommon.Persistence.EFCore;
+using RCommon.Persistence.EFCore.Outbox;
+using RCommon.Persistence.Outbox;
+using RCommon.Persistence.Transactions;
+using Xunit;
+
+namespace Examples.EventHandling.Outbox.Tests;
+
+///
+/// Reproduces the third-party report #16: a host configured with AddOutboxProcessor (the poller
+/// half of the producer/processor topology) rather than AddOutbox. Two shapes are exercised:
+/// 1. Pure poller host, built with ValidateOnBuild — must construct without a DI resolution failure.
+/// 2. Processor host that ALSO commits domain entities raising a durable event — must not silently
+/// drop the event (the #15 failure mode, but reached via the processor-only registration path).
+/// Runs on the EF Core InMemory provider (fast lane).
+///
+public class ProcessorOnlyHostDiTests
+{
+ private static IServiceCollection BaseServices(string db, out IServiceCollection services)
+ {
+ services = new ServiceCollection();
+ services.AddLogging();
+ services.AddRCommon()
+ .WithSimpleGuidGenerator()
+ .WithUnitOfWork(uow => { })
+ .WithPersistence(ef =>
+ {
+ ef.AddDbContext("AppDb", o => o.UseInMemoryDatabase(db));
+ ef.SetDefaultDataStore(ds => ds.DefaultDataStoreName = "AppDb");
+ ef.AddOutboxProcessor(); // PROCESSOR ONLY (no producer)
+ })
+ .WithEventHandling(eh =>
+ {
+ eh.AddSubscriber();
+ eh.Publish().UseOutbox("AppDb");
+ });
+ return services;
+ }
+
+ [Fact]
+ public void ProcessorOnlyHost_BuildsWithValidateOnBuild_WithoutDiResolutionFailure()
+ {
+ BaseServices(Guid.NewGuid().ToString(), out var services);
+
+ Action build = () =>
+ {
+ using var provider = services.BuildServiceProvider(new ServiceProviderOptions
+ {
+ ValidateScopes = true,
+ ValidateOnBuild = true
+ });
+ };
+
+ build.Should().NotThrow("a processor-only host must be constructible (#16)");
+ }
+
+ [Fact]
+ public async Task ProcessorOnlyHost_ThatCommitsDomainEntities_PersistsDurableEvent()
+ {
+ BaseServices(Guid.NewGuid().ToString(), out var services);
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ using (var scope = provider.CreateScope())
+ {
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ }
+
+ using (var scope = provider.CreateScope())
+ {
+ var sp = scope.ServiceProvider;
+ var orders = sp.GetRequiredService>();
+ var uowFactory = sp.GetRequiredService();
+
+ var order = new Order { CustomerName = "Ada Lovelace", Total = 249.99m };
+ order.Place();
+
+ using var uow = uowFactory.Create();
+ await orders.AddAsync(order);
+ await uow.CommitAsync();
+ }
+
+ using (var scope = provider.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var count = await db.Set().AsNoTracking().CountAsync();
+ count.Should().Be(1,
+ "a host that runs the poller and also commits domain entities must persist the durable event");
+ }
+ }
+}
diff --git a/Examples/EventHandling/Examples.EventHandling.Outbox.Tests/ReproOutboxModularDiTests.cs b/Examples/EventHandling/Examples.EventHandling.Outbox.Tests/ReproOutboxModularDiTests.cs
new file mode 100644
index 00000000..87f4d56c
--- /dev/null
+++ b/Examples/EventHandling/Examples.EventHandling.Outbox.Tests/ReproOutboxModularDiTests.cs
@@ -0,0 +1,152 @@
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using RCommon;
+using RCommon.Entities;
+using RCommon.EventHandling;
+using RCommon.Persistence.Crud;
+using RCommon.Persistence.EFCore;
+using RCommon.Persistence.EFCore.Outbox;
+using RCommon.Persistence.Outbox;
+using RCommon.Persistence.Transactions;
+using Xunit;
+
+namespace Examples.EventHandling.Outbox.Tests;
+
+///
+/// Regression for 3.2.0 defect #15 (silent outbox data loss under modular composition).
+///
+/// The customer composes their app from independent modules, each of which calls
+/// WithPersistence<EFCorePersistenceBuilder>(...) for its own bounded context. Only one
+/// module configures the transactional outbox. The others do not.
+///
+/// WithPersistence runs WithEventTracking at the END of every call, which
+/// TryAddScoped<IEntityEventTracker, InMemoryEntityEventTracker>. In 3.2.0 the outbox
+/// producer also registered its tracker with TryAdd — so if any non-outbox module registered
+/// first, the in-memory tracker was pinned and the outbox tracker registration silently no-opped.
+/// Durable events were then dispatched in-process and NEVER written to __OutboxMessages, with
+/// no exception and no warning. This test reproduces that composition (non-outbox datastore first,
+/// outbox datastore second) and asserts the durable event actually lands in the outbox.
+///
+/// Runs on the EF Core InMemory provider (fast lane — no containers). InMemory has no real
+/// transactions, so this proves routing/persistence wiring, not transactional atomicity (that is
+/// covered by the Postgres integration tests).
+///
+public class ReproOutboxModularDiTests
+{
+ // ---- Domain event published durably to the "Billing" outbox ----
+ public sealed class InvoiceRaisedEvent : IDomainEvent
+ {
+ public InvoiceRaisedEvent(Guid invoiceId, decimal amount)
+ {
+ InvoiceId = invoiceId;
+ Amount = amount;
+ }
+
+ public Guid EventId { get; } = Guid.NewGuid();
+ public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
+ public Guid InvoiceId { get; }
+ public decimal Amount { get; }
+ }
+
+ public sealed class Invoice : AggregateRoot
+ {
+ public Invoice() : base(Guid.NewGuid()) { }
+ public string CustomerName { get; set; } = string.Empty;
+ public decimal Amount { get; set; }
+
+ public void Raise() => AddDomainEvent(new InvoiceRaisedEvent(Id, Amount));
+ }
+
+ // ---- "Orders" module datastore — NO outbox. Registered FIRST. ----
+ public sealed class OrdersDbContext : RCommonDbContext
+ {
+ public OrdersDbContext(DbContextOptions options) : base(options) { }
+ protected override void OnModelCreating(ModelBuilder modelBuilder) => base.OnModelCreating(modelBuilder);
+ }
+
+ // ---- "Billing" module datastore — HAS the outbox. Registered SECOND. ----
+ public sealed class BillingDbContext : RCommonDbContext
+ {
+ public BillingDbContext(DbContextOptions options) : base(options) { }
+ public DbSet Invoices => Set();
+ protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.AddOutboxMessages();
+ }
+
+ private static ServiceProvider BuildProvider(string ordersDb, string billingDb)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ services.AddRCommon()
+ .WithSimpleGuidGenerator()
+ .WithUnitOfWork(uow => { })
+ .WithEventHandling(events =>
+ events.Publish().UseOutbox("Billing"))
+ // MODULE 1 (no outbox) — registered FIRST. Its WithEventTracking pins the in-memory
+ // IEntityEventTracker, which is what defeated the outbox registration in 3.2.0.
+ .WithPersistence(ef =>
+ {
+ ef.AddDbContext("Orders", o => o.UseInMemoryDatabase(ordersDb));
+ ef.SetDefaultDataStore(ds => ds.DefaultDataStoreName = "Orders");
+ })
+ // MODULE 2 (outbox) — registered SECOND, as a separate WithPersistence call.
+ .WithPersistence(ef =>
+ {
+ ef.AddDbContext("Billing", o => o.UseInMemoryDatabase(billingDb));
+ ef.AddOutbox(dataStoreName: "Billing");
+ });
+
+ return services.BuildServiceProvider(validateScopes: true);
+ }
+
+ [Fact]
+ public void OutboxTracker_WinsRegistration_UnderModularComposition()
+ {
+ using var provider = BuildProvider(Guid.NewGuid().ToString(), Guid.NewGuid().ToString());
+
+ using var scope = provider.CreateScope();
+ var tracker = scope.ServiceProvider.GetRequiredService();
+
+ tracker.Should().BeOfType(
+ "the transactional outbox must decorate the entity-event tracker even when a non-outbox " +
+ "module's WithPersistence registered the in-memory tracker first (defect #15)");
+ }
+
+ [Fact]
+ public async Task DurableEvent_IsPersisted_UnderModularComposition()
+ {
+ using var provider = BuildProvider(Guid.NewGuid().ToString(), Guid.NewGuid().ToString());
+
+ using (var scope = provider.CreateScope())
+ {
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ }
+
+ using (var scope = provider.CreateScope())
+ {
+ var sp = scope.ServiceProvider;
+ var uowFactory = sp.GetRequiredService();
+ var invoices = sp.GetRequiredService>();
+ invoices.DataStoreName = "Billing";
+
+ var invoice = new Invoice { CustomerName = "Ada Lovelace", Amount = 249.99m };
+ invoice.Raise();
+
+ using var uow = uowFactory.Create();
+ await invoices.AddAsync(invoice);
+ await uow.CommitAsync();
+ }
+
+ using (var scope = provider.CreateScope())
+ {
+ var billing = scope.ServiceProvider.GetRequiredService();
+ var billingOutbox = await billing.Set().AsNoTracking().CountAsync();
+
+ billingOutbox.Should().Be(1,
+ "the durable event must be written to the Billing outbox — under modular composition " +
+ "3.2.0 silently dropped it (0 rows, no error): defect #15");
+ }
+ }
+}
diff --git a/Examples/Examples.sln b/Examples/Examples.sln
index 0e05623d..419d9187 100644
--- a/Examples/Examples.sln
+++ b/Examples/Examples.sln
@@ -195,6 +195,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RCommon.MassTransit.Outbox"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.Messaging.MassTransit.NativeOutbox.Tests", "Messaging\Examples.Messaging.MassTransit.NativeOutbox.Tests\Examples.Messaging.MassTransit.NativeOutbox.Tests.csproj", "{C36A7AFB-2191-4D0F-A7DD-6EB48F0463CE}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.EventHandling.Outbox.Modular", "EventHandling\Examples.EventHandling.Outbox.Modular\Examples.EventHandling.Outbox.Modular.csproj", "{3E253BFF-0598-4321-AAAC-D03AF9F987DF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Examples.EventHandling.Outbox.Modular.Tests", "EventHandling\Examples.EventHandling.Outbox.Modular.Tests\Examples.EventHandling.Outbox.Modular.Tests.csproj", "{24D04D1C-C689-46FD-9A9C-8342A5CDC318}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1141,6 +1145,30 @@ Global
{C36A7AFB-2191-4D0F-A7DD-6EB48F0463CE}.Release|x64.Build.0 = Release|Any CPU
{C36A7AFB-2191-4D0F-A7DD-6EB48F0463CE}.Release|x86.ActiveCfg = Release|Any CPU
{C36A7AFB-2191-4D0F-A7DD-6EB48F0463CE}.Release|x86.Build.0 = Release|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Debug|x64.Build.0 = Debug|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Debug|x86.Build.0 = Debug|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Release|x64.ActiveCfg = Release|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Release|x64.Build.0 = Release|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Release|x86.ActiveCfg = Release|Any CPU
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF}.Release|x86.Build.0 = Release|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Debug|x64.Build.0 = Debug|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Debug|x86.Build.0 = Debug|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Release|Any CPU.Build.0 = Release|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Release|x64.ActiveCfg = Release|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Release|x64.Build.0 = Release|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Release|x86.ActiveCfg = Release|Any CPU
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1220,6 +1248,8 @@ Global
{070131F8-9D2A-41D4-A76E-67582EF68B71} = {AA81E3CF-C1BB-70B4-13CB-5131BB298347}
{60437E30-F5BA-4DC9-8325-01FB4CA2D742} = {AA81E3CF-C1BB-70B4-13CB-5131BB298347}
{C36A7AFB-2191-4D0F-A7DD-6EB48F0463CE} = {AA81E3CF-C1BB-70B4-13CB-5131BB298347}
+ {3E253BFF-0598-4321-AAAC-D03AF9F987DF} = {48A3FC7F-661D-68E4-77B8-76A1C7AA7E61}
+ {24D04D1C-C689-46FD-9A9C-8342A5CDC318} = {48A3FC7F-661D-68E4-77B8-76A1C7AA7E61}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B0CD26D-8067-4667-863E-6B0EE7EDAA42}
diff --git a/Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs b/Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs
index 96915056..ba66f88b 100644
--- a/Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs
+++ b/Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs
@@ -16,8 +16,9 @@ public static class OutboxPersistenceBuilderExtensions
/// then .
///
///
- /// All registrations are idempotent (Try*/TryAddEnumerable) and datastore names
- /// deduplicate case-insensitively, so composing producer + processor (which each register the
+ /// Every registration is idempotent — via Try*/TryAddEnumerable, or (for the
+ /// authoritative outbox routing) a Remove-then-Add that nets exactly one registration — and datastore
+ /// names deduplicate case-insensitively, so composing producer + processor (which each register the
/// shared core) is safe and yields exactly one poller and one datastore registration.
///
/// The delegate may be invoked more than once (once eagerly to resolve
@@ -53,24 +54,13 @@ public static IPersistenceBuilder AddOutboxProducer(
string? dataStoreName = null)
where TOutboxStore : class, IOutboxStore
{
+ // Routing (tracker + routers) lives in AddOutboxCore so that EVERY outbox host — producer,
+ // processor, or the combined AddOutbox — persists durable events. See AddOutboxCore for why.
builder.AddOutboxCore(configure, dataStoreName);
- // Outbox event router (scoped, concrete) + IEventRouter forwarder.
- builder.Services.TryAddScoped();
- builder.Services.TryAddScoped(sp => sp.GetRequiredService());
-
- // In-process transactional router as its OWN concrete scoped type (the transient dispatcher the
- // OutboxEntityEventTracker composes directly for the Phase-2 FIFO drain, independent of whatever
- // IEventRouter resolves to in the outbox host).
- builder.Services.TryAddScoped();
-
- // Entity event tracker decorator (scoped — replaces InMemoryEntityEventTracker).
- builder.Services.TryAddScoped();
- builder.Services.TryAddScoped();
-
- // Startup diagnostic: warn if a later registration silently overrode the outbox IEventRouter.
- // Producer-only: it inspects the producer's IEventRouter -> OutboxEventRouter binding, which a
- // processor-only host never registers, so running it there would false-warn.
+ // Startup diagnostic: warn if a later registration silently overrode the outbox tracker.
+ // Producer-only: a processor-only host legitimately never commits domain entities, so warning
+ // there would be noise; the shared MN-3 durable-route validator (in the core) covers both hosts.
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton(
sp => new OutboxRoutingDiagnosticsHostedService(
@@ -124,6 +114,40 @@ private static IPersistenceBuilder AddOutboxCore(
// Outbox store (scoped — participates in per-request transaction).
builder.Services.TryAddScoped();
+ // Outbox routing — registered in the CORE (shared by producer, processor, and the combined
+ // AddOutbox) so ANY host that both runs the outbox and commits domain entities persists durable
+ // events. A pure poller host that never commits entities simply never constructs the tracker.
+ //
+ // Concrete collaborators the OutboxEntityEventTracker composes directly. Safe to TryAdd: they are
+ // the outbox's own types, so nothing else registers them. Registering all three together (not just
+ // the interface binding) is what prevents the "unable to resolve InMemoryEntityEventTracker" DI
+ // failure when the outbox tracker is bound (report #16). The InMemoryTransactionalEventRouter is the
+ // transient dispatcher for the Phase-2 FIFO drain, independent of whatever IEventRouter resolves to.
+ builder.Services.TryAddScoped();
+ builder.Services.TryAddScoped();
+ builder.Services.TryAddScoped();
+
+ // Authoritative outbox routing. These MUST win regardless of the order in which WithPersistence /
+ // WithEventHandling / AddRCommon ran, so they are registered with Remove-then-Add rather than TryAdd.
+ //
+ // Why TryAdd is wrong here (3.2.0 defect #15 — silent outbox data loss):
+ // * WithEventTracking runs at the END of EVERY WithPersistence call and TryAdds the in-memory
+ // IEntityEventTracker. Under modular / multi-datastore composition a non-outbox WithPersistence
+ // can run first, pinning the in-memory tracker; a later AddOutbox TryAdd then no-ops and every
+ // durable event is silently dispatched in-process and never written to the outbox.
+ // * RCommonBuilder's constructor registers IEventRouter -> InMemoryTransactionalEventRouter with an
+ // unconditional AddScoped, so an outbox IEventRouter forwarder registered with TryAdd could never
+ // win in ANY configuration.
+ // OutboxEntityEventTracker is a strict superset of the in-memory tracker (durable events -> outbox,
+ // transient events -> in-process), so it is always correct for it to win. Remove-then-Add is
+ // idempotent across repeated AddOutbox calls (it nets exactly one registration), and a subsequent
+ // non-outbox WithPersistence TryAdd will no-op because the outbox registration is already present.
+ builder.Services.RemoveAll();
+ builder.Services.AddScoped(sp => sp.GetRequiredService());
+
+ builder.Services.RemoveAll();
+ builder.Services.AddScoped();
+
// Serializer (singleton, replaceable).
builder.Services.TryAddSingleton();
diff --git a/Src/RCommon.Persistence/Outbox/OutboxRoutingDiagnosticsHostedService.cs b/Src/RCommon.Persistence/Outbox/OutboxRoutingDiagnosticsHostedService.cs
index bb2dd8a8..f0fddb07 100644
--- a/Src/RCommon.Persistence/Outbox/OutboxRoutingDiagnosticsHostedService.cs
+++ b/Src/RCommon.Persistence/Outbox/OutboxRoutingDiagnosticsHostedService.cs
@@ -4,7 +4,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
-using RCommon.EventHandling.Producers;
+using RCommon.Entities;
namespace RCommon.Persistence.Outbox;
@@ -26,19 +26,22 @@ public OutboxRoutingDiagnosticsHostedService(IServiceCollection services, ILogge
public Task StartAsync(CancellationToken cancellationToken)
{
- // DI is last-registration-wins. AddOutbox binds IEventRouter to the OutboxEventRouter (via a
- // forwarding factory, so its ImplementationType is null). If a later event-handling registration
- // binds the in-memory router, that wins and outbox routing is silently defeated -- events fire
- // in-process post-commit and are never persisted to the outbox.
- var lastRouter = _services.LastOrDefault(d => d.ServiceType == typeof(IEventRouter));
- if (lastRouter?.ImplementationType == typeof(InMemoryTransactionalEventRouter))
+ // DI is last-registration-wins. Durable-event persistence rides on IEntityEventTracker resolving to
+ // OutboxEntityEventTracker -- the tracker composes the concrete OutboxEventRouter directly, so the
+ // IEventRouter binding is NOT what persists events (checking it produced false positives, since the
+ // core ctor pins IEventRouter to the in-memory router unconditionally even on a working outbox host).
+ // AddOutbox now binds the tracker authoritatively (Remove-then-Add), so the only way it can be the
+ // in-memory tracker at startup is an explicit later override -- which silently defeats the outbox
+ // (durable events fire in-process post-commit and are never persisted). Surface that.
+ var lastTracker = _services.LastOrDefault(d => d.ServiceType == typeof(IEntityEventTracker));
+ if (lastTracker?.ImplementationType == typeof(InMemoryEntityEventTracker))
{
_loggerFactory?.CreateLogger().LogWarning(
- "RCommon outbox is configured (AddOutbox) but the effective IEventRouter is the in-memory " +
- "router ({Router}); a later registration overrode the outbox router. Domain events will be " +
- "dispatched in-memory and NOT persisted to the outbox. Register AddOutbox after any " +
- "event-handling configuration that binds IEventRouter, or re-assert the outbox router last.",
- typeof(InMemoryTransactionalEventRouter).Name);
+ "RCommon outbox is configured (AddOutbox) but the effective IEntityEventTracker is the " +
+ "in-memory tracker ({Tracker}); a later registration overrode the outbox tracker. Durable " +
+ "domain events will be dispatched in-memory and NOT persisted to the outbox. Remove the " +
+ "override, or re-assert the outbox tracker last.",
+ typeof(InMemoryEntityEventTracker).Name);
}
return Task.CompletedTask;
diff --git a/Tests/RCommon.IntegrationTests/MultiDataStoreUnitOfWorkSemanticsTests.cs b/Tests/RCommon.IntegrationTests/MultiDataStoreUnitOfWorkSemanticsTests.cs
new file mode 100644
index 00000000..f586f8de
--- /dev/null
+++ b/Tests/RCommon.IntegrationTests/MultiDataStoreUnitOfWorkSemanticsTests.cs
@@ -0,0 +1,234 @@
+using System;
+using System.Threading.Tasks;
+using System.Transactions;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Npgsql;
+using RCommon.Entities;
+using RCommon.EventHandling;
+using RCommon.IntegrationTests.Fixtures;
+using RCommon.Persistence.Crud;
+using RCommon.Persistence.EFCore;
+using RCommon.Persistence.EFCore.Outbox;
+using RCommon.Persistence.Outbox;
+using RCommon.Persistence.Transactions;
+using Xunit;
+
+namespace RCommon.IntegrationTests;
+
+///
+/// Pins the transactional contract for a unit of work that spans MORE THAN ONE datastore — the question
+/// a heterogeneous-engine deployment (e.g. Postgres + SQL Server) runs into in production.
+///
+/// THE CONTRACT (what these tests assert):
+/// 1. An outbox row is atomic with ITS OWN datastore's state change — they share one connection/local
+/// transaction (co-location, spec MN-4). The outbox never enlists a second resource manager.
+/// 2. There is NO distributed atomicity across datastores. RCommon's wraps a
+/// single ambient (Required). If one unit of work writes to a SECOND
+/// datastore, a second physical connection enlists and the ambient transaction promotes to a
+/// distributed transaction (2PC). On Postgres that means PREPARE TRANSACTION, which is
+/// disabled by default (max_prepared_transactions = 0) and fails loud — it never silently
+/// half-commits. (Postgres + SQL Server would promote to MSDTC with the same "no cross-datastore
+/// atomicity" outcome. Two Postgres databases reproduce the identical promotion because the trigger
+/// is "a second connection in one scope," not the engine pairing.)
+/// 3. Therefore an application that writes state to more than one datastore in one logical operation
+/// must split it into a UNIT OF WORK PER DATASTORE. Each commits locally and atomically (state +
+/// that datastore's outbox row); cross-datastore delivery is eventual (at-least-once via the poller).
+///
+/// Modelled as two distinct Postgres databases on one container (each context points at its own
+/// Database=), so each owns an isolated __OutboxMessages table.
+///
+[Trait("Category", "Integration")]
+[Collection(PostgreSqlCollection.Name)]
+public class MultiDataStoreUnitOfWorkSemanticsTests
+{
+ private readonly PostgreSqlFixture _pg;
+
+ public MultiDataStoreUnitOfWorkSemanticsTests(PostgreSqlFixture pg) => _pg = pg;
+
+ // ---- Ordering datastore ----
+ public sealed class OrderPlacedEvent : IDomainEvent
+ {
+ public OrderPlacedEvent(Guid orderId) => OrderId = orderId;
+ public Guid EventId { get; } = Guid.NewGuid();
+ public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
+ public Guid OrderId { get; }
+ }
+
+ public sealed class Order : AggregateRoot
+ {
+ public Order() : base(Guid.NewGuid()) { }
+ public string CustomerName { get; set; } = string.Empty;
+ public void Place() => AddDomainEvent(new OrderPlacedEvent(Id));
+ }
+
+ public sealed class OrdersDbContext : RCommonDbContext
+ {
+ public OrdersDbContext(DbContextOptions options) : base(options) { }
+ public DbSet Orders => Set();
+ }
+
+ // ---- Billing datastore (a genuinely separate database) ----
+ public sealed class InvoiceRaisedEvent : IDomainEvent
+ {
+ public InvoiceRaisedEvent(Guid invoiceId) => InvoiceId = invoiceId;
+ public Guid EventId { get; } = Guid.NewGuid();
+ public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
+ public Guid InvoiceId { get; }
+ }
+
+ public sealed class Invoice : AggregateRoot
+ {
+ public Invoice() : base(Guid.NewGuid()) { }
+ public string CustomerName { get; set; } = string.Empty;
+ public void Raise() => AddDomainEvent(new InvoiceRaisedEvent(Id));
+ }
+
+ public sealed class BillingDbContext : RCommonDbContext
+ {
+ public BillingDbContext(DbContextOptions options) : base(options) { }
+ public DbSet Invoices => Set();
+ }
+
+ private ServiceProvider BuildProvider(string ordersConnectionString, string billingConnectionString)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ services.AddRCommon()
+ .WithSimpleGuidGenerator()
+ .WithUnitOfWork(uow => { })
+ .WithEventHandling(events =>
+ {
+ // Each event is durable to ITS OWN co-located datastore's outbox.
+ events.Publish().UseOutbox("Orders");
+ events.Publish().UseOutbox("Billing");
+ })
+ .WithPersistence(ef =>
+ {
+ ef.AddDbContext("Orders", o => o.UseNpgsql(ordersConnectionString));
+ ef.AddDbContext("Billing", o => o.UseNpgsql(billingConnectionString));
+ ef.SetDefaultDataStore(ds => ds.DefaultDataStoreName = "Orders");
+ ef.AddOutbox(dataStoreName: "Orders");
+ ef.AddOutbox(dataStoreName: "Billing");
+ });
+
+ return services.BuildServiceProvider(validateScopes: true);
+ }
+
+ private static async Task EnsureSchemasAsync(IServiceProvider provider)
+ {
+ using var scope = provider.CreateScope();
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync();
+ }
+
+ private static async Task<(int orders, int ordersOutbox, int invoices, int billingOutbox)> CountAsync(IServiceProvider provider)
+ {
+ using var scope = provider.CreateScope();
+ var orders = scope.ServiceProvider.GetRequiredService();
+ var billing = scope.ServiceProvider.GetRequiredService();
+ return (
+ await orders.Set().AsNoTracking().CountAsync(),
+ await orders.Set().AsNoTracking().CountAsync(),
+ await billing.Set().AsNoTracking().CountAsync(),
+ await billing.Set().AsNoTracking().CountAsync());
+ }
+
+ [Fact]
+ public async Task Single_unit_of_work_writing_two_datastores_fails_loud_and_commits_nothing()
+ {
+ var ordersCs = await _pg.CreateUniqueDatabaseAsync("orders");
+ var billingCs = await _pg.CreateUniqueDatabaseAsync("billing");
+ await using var provider = BuildProvider(ordersCs, billingCs);
+ await EnsureSchemasAsync(provider);
+
+ TransactionAbortedException? thrown = null;
+ try
+ {
+ using var scope = provider.CreateScope();
+ var sp = scope.ServiceProvider;
+ var uowFactory = sp.GetRequiredService();
+
+ var orders = sp.GetRequiredService>();
+ orders.DataStoreName = "Orders";
+ var invoices = sp.GetRequiredService>();
+ invoices.DataStoreName = "Billing";
+
+ var order = new Order { CustomerName = "Ada Lovelace" };
+ order.Place();
+ var invoice = new Invoice { CustomerName = "Ada Lovelace" };
+ invoice.Raise();
+
+ using var uow = uowFactory.Create();
+ await orders.AddAsync(order); // enlists the Orders connection
+ await invoices.AddAsync(invoice); // enlists a SECOND connection => ambient tx promotes to 2PC
+ await uow.CommitAsync(); // PREPARE TRANSACTION rejected by Postgres
+ }
+ catch (TransactionAbortedException ex)
+ {
+ thrown = ex;
+ }
+
+ thrown.Should().NotBeNull(
+ "a single unit of work spanning two datastores promotes to a distributed transaction; RCommon " +
+ "provides NO cross-datastore atomicity and must fail loud rather than half-commit");
+ thrown!.InnerException.Should().BeOfType()
+ .Which.SqlState.Should().Be("55000",
+ "Postgres rejects PREPARE TRANSACTION when prepared transactions are disabled (the default)");
+
+ var (orderRows, ordersOutbox, invoiceRows, billingOutbox) = await CountAsync(provider);
+ orderRows.Should().Be(0, "the aborted transaction must leave no Orders state");
+ ordersOutbox.Should().Be(0, "the aborted transaction must leave no Orders outbox row");
+ invoiceRows.Should().Be(0, "the aborted transaction must leave no Billing state");
+ billingOutbox.Should().Be(0, "the aborted transaction must leave no Billing outbox row");
+ }
+
+ [Fact]
+ public async Task Per_datastore_unit_of_work_scopes_each_commit_locally_with_their_own_outbox_row()
+ {
+ var ordersCs = await _pg.CreateUniqueDatabaseAsync("orders");
+ var billingCs = await _pg.CreateUniqueDatabaseAsync("billing");
+ await using var provider = BuildProvider(ordersCs, billingCs);
+ await EnsureSchemasAsync(provider);
+
+ // ONE logical operation, split into a unit of work PER datastore. Each is a single connection, so
+ // each commits with a local transaction (no promotion) — state + that datastore's outbox row.
+ using (var scope = provider.CreateScope())
+ {
+ var sp = scope.ServiceProvider;
+ var uowFactory = sp.GetRequiredService();
+ var orders = sp.GetRequiredService>();
+ orders.DataStoreName = "Orders";
+
+ var order = new Order { CustomerName = "Ada Lovelace" };
+ order.Place();
+
+ using var uow = uowFactory.Create();
+ await orders.AddAsync(order);
+ await uow.CommitAsync();
+ }
+
+ using (var scope = provider.CreateScope())
+ {
+ var sp = scope.ServiceProvider;
+ var uowFactory = sp.GetRequiredService();
+ var invoices = sp.GetRequiredService>();
+ invoices.DataStoreName = "Billing";
+
+ var invoice = new Invoice { CustomerName = "Ada Lovelace" };
+ invoice.Raise();
+
+ using var uow = uowFactory.Create();
+ await invoices.AddAsync(invoice);
+ await uow.CommitAsync();
+ }
+
+ var (orderRows, ordersOutbox, invoiceRows, billingOutbox) = await CountAsync(provider);
+ orderRows.Should().Be(1, "the Orders unit of work committed locally");
+ ordersOutbox.Should().Be(1, "the Order's durable event is atomic with the Orders state change");
+ invoiceRows.Should().Be(1, "the Billing unit of work committed locally");
+ billingOutbox.Should().Be(1, "the Invoice's durable event is atomic with the Billing state change");
+ }
+}
diff --git a/Tests/RCommon.Persistence.Tests/OutboxHostRouterResolutionTests.cs b/Tests/RCommon.Persistence.Tests/OutboxHostRouterResolutionTests.cs
index 0819faf4..f67bfdf2 100644
--- a/Tests/RCommon.Persistence.Tests/OutboxHostRouterResolutionTests.cs
+++ b/Tests/RCommon.Persistence.Tests/OutboxHostRouterResolutionTests.cs
@@ -104,20 +104,22 @@ public void OutboxEntityEventTracker_ResolvesWithAllFourConstructorDependencies(
}
[Fact]
- public void IEventRouter_IsNotTheSameInstanceAsTheConcreteInProcessRouter()
+ public void IEventRouter_ResolvesToTheOutboxForwarder_AndIsDistinctFromTheConcreteInProcessRouter()
{
- // Informative: the tracker's transient dispatcher (the concrete InMemoryTransactionalEventRouter it
- // composes) must be a separate object from whatever IEventRouter resolves to in the outbox host.
- // In this host IEventRouter actually resolves to InMemoryTransactionalEventRouter (core registers it
- // FIRST via AddScoped, so AddOutbox's TryAddScoped -> OutboxEventRouter is a no-op).
- // Even so, the concrete scoped InMemoryTransactionalEventRouter is a DISTINCT registration/instance
- // from the IEventRouter binding, which is what the concrete-injection design guarantees.
+ // As of the 3.2.1 defect-#15 fix, AddOutbox registers IEventRouter authoritatively (Remove-then-Add
+ // a forwarder to OutboxEventRouter) so it WINS over the in-memory router the core ctor registers
+ // first — regardless of registration order. IEventRouter therefore resolves to the OutboxEventRouter.
+ // The tracker's OWN transient dispatcher (the concrete InMemoryTransactionalEventRouter it composes)
+ // remains a DISTINCT registration/instance from the IEventRouter binding, which is what the
+ // concrete-injection design guarantees.
using var provider = BuildOutboxHost();
using var scope = provider.CreateScope();
var eventRouter = scope.ServiceProvider.GetRequiredService();
var concreteInProcess = scope.ServiceProvider.GetRequiredService();
+ eventRouter.Should().BeOfType(
+ "AddOutbox must authoritatively bind IEventRouter to the outbox forwarder (defect #15)");
eventRouter.Should().NotBeSameAs(concreteInProcess,
"the tracker's composed transient dispatcher must be independent of the host's IEventRouter binding");
}
diff --git a/Tests/RCommon.Persistence.Tests/OutboxRoutingDiagnosticsHostedServiceTests.cs b/Tests/RCommon.Persistence.Tests/OutboxRoutingDiagnosticsHostedServiceTests.cs
index f9dda8e5..69390fed 100644
--- a/Tests/RCommon.Persistence.Tests/OutboxRoutingDiagnosticsHostedServiceTests.cs
+++ b/Tests/RCommon.Persistence.Tests/OutboxRoutingDiagnosticsHostedServiceTests.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
+using RCommon.Entities;
using RCommon.EventHandling.Producers;
using RCommon.Persistence.Outbox;
using Xunit;
@@ -11,11 +12,12 @@
namespace RCommon.Persistence.Tests;
///
-/// Covers the outbox-routing footgun: because DI is last-registration-wins, an event-handling
-/// registration that binds the in-memory AFTER
-/// silently overrides the
-/// outbox router. When that happens, domain events fire in-memory and are never persisted to the
-/// outbox. The diagnostic surfaces the misconfiguration at startup instead of leaving it silent.
+/// Covers the outbox-routing footgun on the LOAD-BEARING service. Durable-event persistence rides on
+/// resolving to — NOT on the
+/// binding (the tracker composes the concrete
+/// directly). If a later registration binds the in-memory tracker after the outbox producer configured
+/// its tracker, durable events fire in-process and are never persisted, silently. The diagnostic
+/// surfaces that at startup instead of leaving it silent.
///
public class OutboxRoutingDiagnosticsHostedServiceTests
{
@@ -40,13 +42,14 @@ private static void VerifyWarning(Mock logger, Times times)
}
[Fact]
- public async Task StartAsync_Warns_When_Outbox_Router_Is_Clobbered_By_InMemory_Router()
+ public async Task StartAsync_Warns_When_Outbox_Tracker_Is_Clobbered_By_InMemory_Tracker()
{
var services = new ServiceCollection();
- // Outbox registers IEventRouter via a factory forwarding to OutboxEventRouter...
- services.AddScoped(_ => throw new InvalidOperationException("should not resolve"));
- // ...then a later event-handling registration binds the in-memory router last (the footgun).
- services.AddScoped();
+ // Outbox binds IEntityEventTracker -> OutboxEntityEventTracker...
+ services.AddScoped();
+ // ...then a later registration binds the in-memory tracker last (the footgun): durable events
+ // would fire in-process and never be persisted.
+ services.AddScoped();
var (loggerFactory, logger) = CreateLogger();
var diagnostic = new OutboxRoutingDiagnosticsHostedService(services, loggerFactory.Object);
@@ -57,11 +60,12 @@ public async Task StartAsync_Warns_When_Outbox_Router_Is_Clobbered_By_InMemory_R
}
[Fact]
- public async Task StartAsync_Does_Not_Warn_When_Outbox_Router_Is_Intact()
+ public async Task StartAsync_Does_Not_Warn_When_Outbox_Tracker_Is_Intact()
{
var services = new ServiceCollection();
- // Intact: the last IEventRouter registration is the outbox factory (not the in-memory router).
- services.AddScoped(_ => throw new InvalidOperationException("should not resolve"));
+ // Intact: the last IEntityEventTracker registration is the outbox tracker.
+ services.AddScoped();
+ services.AddScoped();
var (loggerFactory, logger) = CreateLogger();
var diagnostic = new OutboxRoutingDiagnosticsHostedService(services, loggerFactory.Object);
diff --git a/docs/specs/event-handling/event-handling.md b/docs/specs/event-handling/event-handling.md
index 28775ce3..3982777c 100644
--- a/docs/specs/event-handling/event-handling.md
+++ b/docs/specs/event-handling/event-handling.md
@@ -34,9 +34,9 @@ This domain spec formalizes the 3.2.0 redesign. Full design rationale, diagrams,
- **AC-15 (broker coordination proven):** An integration test on the Podman/Testcontainers harness asserts that, under recipe 2b, business state + broker-outbox rows commit atomically and a rollback leaves neither. Recipe 2b is "done" only when green.
- **AC-16 (recipes proven):** Each of the five recipes ships as a runnable example with an end-to-end test asserting the documented wiring composes and produces the recipe's observable outcome.
- **AC-17 (back-compat shims):** `IEntityEventTracker.AddEntity(entity)` overload preserved (defaults to default datastore); `AddSubscriber` retained as `[Obsolete]` alias forwarding to `Consume` on broker builders; `EFCoreOutboxStore`/subclass marked `[Obsolete]`.
-- **AC-18 (opt-in metrics):** RCommon exposes a `RCommon.Outbox` `System.Diagnostics.Metrics.Meter` instrumenting per-datastore pending depth, oldest-unprocessed age, relay success/failure counts, dead-letter rate, and dispatch-queue depth. Opt-in (host registers the meter with its metrics pipeline); additive/non-breaking.
-- **AC-19 (payload protection hook):** An `IOutboxPayloadProtector` seam wraps payload serialization with `Protect`/`Unprotect`; the default implementation is pass-through (plaintext). Applications may supply an encrypting implementation. Non-breaking.
-- **AC-20 (deserialization allow-list):** The outbox serializer resolves only event types present in the registration set (types with a route/subscriber/producer). An unknown/unresolvable `EventType` on relay/consume is logged loud and dead-lettered — never deserialized to an arbitrary type.
+- **AC-18 (opt-in metrics) — DEFERRED, not shipped in 3.2.0.** Planned: RCommon exposes a `RCommon.Outbox` `System.Diagnostics.Metrics.Meter` instrumenting per-datastore pending depth, oldest-unprocessed age, relay success/failure counts, dead-letter rate, and dispatch-queue depth. Opt-in (host registers the meter with its metrics pipeline); additive/non-breaking. **Current state:** not implemented; observe via the `__OutboxMessages` tables or poller logs.
+- **AC-19 (payload protection hook) — DEFERRED, not shipped in 3.2.0.** Planned: an `IOutboxPayloadProtector` seam wrapping payload serialization with `Protect`/`Unprotect`; default pass-through (plaintext), applications may supply an encrypting implementation. Non-breaking. **Current state:** not implemented; payloads are plaintext within the application-database trust boundary.
+- **AC-20 (deserialization allow-list) — DEFERRED, not shipped in 3.2.0.** Planned: the outbox serializer resolves only event types present in the registration set (types with a route/subscriber/producer); an unknown/unresolvable `EventType` on relay/consume is logged loud and dead-lettered — never deserialized to an arbitrary type. **Current state:** not implemented; the serializer resolves the type named in the row.
- **AC-21 (producer/processor topology):** First-class `AddOutboxProducer` (store/router/tracker, no hosted poller) and `AddOutboxProcessor` (hosted poller) registration methods exist alongside `AddOutbox` (= producer + processor). Each is datastore-scoped (`OnDataStore(...)`), consolidating the multi-host topology into this release's registration rework.
### Must Not Do
@@ -50,7 +50,7 @@ This domain spec formalizes the 3.2.0 redesign. Full design rationale, diagrams,
### Nice to Have
-- None outstanding. Items previously parked here — the producer/processor topology split, payload protection, and first-class metrics — were pulled into scope during spec review (AC-18, AC-19, AC-21).
+- The producer/processor topology split (AC-21) was pulled into scope during spec review and **shipped** in 3.2.0. Payload protection (AC-19) and first-class metrics (AC-18) were also accepted into scope but were **deferred and did not ship in 3.2.0** — they remain roadmap items (see Open Questions).
## Technical Constraints
@@ -76,14 +76,14 @@ External dependencies: one relational database per registered datastore; optiona
- **Warnings (fail-loud):** poller draining an event type with zero matching subscribers (once per type); outbox routing overridden by a later registration (startup diagnostic); missing outbox schema on a registered datastore (startup diagnostic); cycle-breaker generation limit exceeded; best-effort relay/dispatch failure (before retry); dead-lettering.
- **Debug/Info:** dispatch counts per commit, poller poll cycles and claim counts, `ImmediateDispatch` skip on producer-only hosts.
-- **Metrics (first-class, opt-in):** a `RCommon.Outbox` `Meter` (System.Diagnostics.Metrics) exposes per-datastore outbox pending depth and oldest-unprocessed age; relay success/failure counts; dead-letter rate; dispatch-queue depth and max cascade generation reached (AC-18). Host-agnostic — the application wires the meter into OpenTelemetry/Prometheus/etc.
+- **Metrics (first-class, opt-in) — DEFERRED, not shipped in 3.2.0 (AC-18).** Planned: a `RCommon.Outbox` `Meter` (System.Diagnostics.Metrics) exposing per-datastore outbox pending depth and oldest-unprocessed age; relay success/failure counts; dead-letter rate; dispatch-queue depth and max cascade generation reached. Host-agnostic — the application wires the meter into OpenTelemetry/Prometheus/etc. Until it ships, observe via the `__OutboxMessages` tables or the poller's warnings/logs.
- **Alerting:** outbox backlog age exceeding a threshold and dead-letter rate are the primary signals; thresholds are host-owned.
## Security
- **Attack surface:** event payloads are serialized into the outbox (JSON) and to brokers, then deserialized on relay/consume. Type resolution goes through `IOutboxSerializer`; only known/registered event types should be deserialized.
-- **Data protection:** outbox rows live in the application database, inside the same trust boundary and at-rest protection as business data. `TenantId` is recorded per row for multi-tenant isolation. Payloads are plaintext by default; applications needing field/payload protection supply an `IOutboxPayloadProtector` (AC-19, default pass-through).
-- **Deserialization safety:** the serializer enforces an allow-list of registered event types; a tampered/unknown `EventType` is logged and dead-lettered rather than deserialized (AC-20).
+- **Data protection:** outbox rows live in the application database, inside the same trust boundary and at-rest protection as business data. `TenantId` is recorded per row for multi-tenant isolation. Payloads are plaintext. The `IOutboxPayloadProtector` field/payload-protection seam (AC-19) is **DEFERRED — not shipped in 3.2.0**; applications needing payload protection must handle it above the event model until it ships.
+- **Deserialization safety — DEFERRED, not shipped in 3.2.0 (AC-20).** Planned: the serializer enforces an allow-list of registered event types and a tampered/unknown `EventType` is logged and dead-lettered rather than deserialized. **Current state:** the serializer resolves the type named in the outbox row; deploy the outbox only within a trusted boundary until the allow-list ships.
- **Auth/authz:** not applicable at the library level; consumers execute in their own DI scope. Inbox idempotency prevents duplicate side effects from replays.
- **Compliance:** none imposed by the library; applications remain responsible for any PII/regulatory handling of event payloads.
@@ -110,13 +110,13 @@ Breaking changes are softened with shims (AC-17). Deliverables: a migration guid
## Open Questions
-None outstanding. All were resolved during spec review (2026-07-22):
+Scope was resolved during spec review (2026-07-22), but three accepted items (AC-18/19/20) were **deferred and did not ship in 3.2.0** — reopened below as roadmap items:
-- **OQ-1 (metrics) → resolved:** first-class, opt-in `RCommon.Outbox` `Meter` (AC-18).
-- **OQ-2 (payload protection) → resolved:** optional `IOutboxPayloadProtector`, default pass-through (AC-19).
+- **OQ-1 (metrics) → DEFERRED:** first-class, opt-in `RCommon.Outbox` `Meter` (AC-18) is designed but not implemented in 3.2.0.
+- **OQ-2 (payload protection) → DEFERRED:** optional `IOutboxPayloadProtector` (AC-19) is designed but not implemented in 3.2.0.
- **OQ-3 (perf) → resolved:** correctness-focused + sanity throughput assertions; no formal benchmark suite in 3.2.0.
- **OQ-4 (cycle-breaker default) → resolved:** 16 generations, configurable (AC-4).
-- **OQ-5 (deserialization allow-list) → resolved:** enforce allow-list of registered types; unknown ⇒ fail-loud/dead-letter (AC-20).
+- **OQ-5 (deserialization allow-list) → DEFERRED:** the registered-type allow-list (AC-20) is designed but not implemented in 3.2.0.
- **OQ-6 (topology API) → resolved:** fold `AddOutboxProducer`/`AddOutboxProcessor` into 3.2.0 (AC-21).
- **OQ-7 (example names) → resolved:** accept the proposed names, including `Examples.EventHandling.Outbox.MultiDataStore`, `Examples.Messaging.MassTransit.NativeOutbox`, `Examples.Messaging.Wolverine.NativeOutbox`, `Examples.EventHandling.TransactionScript`, `Examples.EventHandling.NoUnitOfWork`.
diff --git a/docs/superpowers/plans/2026-07-23-outbox-di-registration-3.2.1.md b/docs/superpowers/plans/2026-07-23-outbox-di-registration-3.2.1.md
new file mode 100644
index 00000000..3735884e
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-23-outbox-di-registration-3.2.1.md
@@ -0,0 +1,106 @@
+# Outbox DI Registration Hardening (3.2.1) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development for every task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Eliminate the silent-data-loss defect where the transactional outbox stops persisting durable events under modular / multi-`WithPersistence` composition, harden the processor-only host registration, correct the 3.2.0 docs that describe unshipped features, and ship a worked modular multi-datastore example.
+
+**Architecture:** The outbox producer decorates two core services — `IEntityEventTracker` (→ `OutboxEntityEventTracker`) and `IEventRouter` (→ `OutboxEventRouter` forwarder). Today both are registered with `TryAdd*`, and `RCommonBuilder`'s constructor registers `IEventRouter` with an unconditional `AddScoped`. So (a) the outbox `IEventRouter` forwarder *always* no-ops, and (b) the outbox `IEntityEventTracker` no-ops whenever a prior non-outbox `WithPersistence` call already registered the in-memory tracker via `WithEventTracking`. The fix makes the outbox registration **authoritative** (remove-then-add) so it wins regardless of call ordering, since `OutboxEntityEventTracker` is a strict superset of the in-memory tracker (durable → outbox, transient → in-process).
+
+**Tech Stack:** .NET 10, `Microsoft.Extensions.DependencyInjection`, xUnit + FluentAssertions, EF Core InMemory provider (fast lane, no containers).
+
+**Release:** 3.2.1 (patch; no public API surface change — behavior-only correction + additive diagnostic).
+
+---
+
+## Background — verified root causes (3.2.0 source)
+
+- **#15 (High, silent data loss).** `OutboxPersistenceBuilderExtensions.AddOutboxProducer` uses `TryAddScoped` (line 69). `PersistenceBuilderExtensions.WithEventTracking` runs at the end of **every** `WithPersistence` call and does `TryAddScoped` (line 87). If any non-outbox `WithPersistence` runs before the outbox one (modular composition, second datastore, separate provider), the in-memory tracker is pinned first and the outbox `TryAdd` no-ops → durable events are dispatched in-process and **never written to the outbox**, with no exception and no warning. Single-`WithPersistence` happy path works because the `AddOutbox` call inside the delegate runs before `WithEventTracking`.
+- **#15 nuance.** The customer attributed the root cause to the `IEventRouter` no-op. That no-op is real (`RCommonBuilder` ctor `AddScoped` unconditionally, so `AddOutboxProducer`'s `TryAddScoped` forwarder always no-ops) but it is **not** what breaks persistence — `OutboxEntityEventTracker` composes the *concrete* `OutboxEventRouter`, not the `IEventRouter` alias. Both are fixed here for correctness/consistency, but the load-bearing fix is `IEntityEventTracker`.
+- **#15 diagnostic gap.** `OutboxRoutingDiagnosticsHostedService` only inspects `IEventRouter`'s last descriptor. Because the core ctor pins `IEventRouter` to the in-memory impl unconditionally, this diagnostic warns on **every** outbox configuration including the working one (false positive) and never inspects the load-bearing `IEntityEventTracker`. Fix it to inspect the tracker.
+- **#16 (processor-only host).** `AddOutboxProcessor` registers only the shared core + hosted poller. To be reproduced with a DI smoke test (validateOnBuild) before choosing the fix; the poller itself resolves `IOutboxStore`, `IOutboxSerializer`, `IEventProducer[]`, `EventSubscriptionManager`, `IOutboxDataStoreRegistry`, `IBackoffStrategy`, `IOptions`, `IOptions` — verify which is missing on a processor-only host and register it (or document the dual-call requirement).
+- **#17 (docs over-claim).** The 3.2.0 changelog "Added" list includes an outbox metrics `Meter`, `IOutboxPayloadProtector`, and a deserialization allow-list (spec AC-18/19/20). Grep confirms these are **absent** from shipped `Src`. Correct changelog/migration/spec to mark them planned-not-shipped.
+
+---
+
+## Task 1: #15 regression test — modular composition drops durable events
+
+**Files:**
+- Test: `Examples/EventHandling/Examples.EventHandling.Outbox.Tests/ReproOutboxModularDiTests.cs` (create)
+
+- [ ] **Step 1: Write the failing test.** Build a provider that mirrors modular composition: two separate `WithPersistence` calls (or one non-outbox datastore registered before the outbox datastore) so `WithEventTracking` pins the in-memory tracker before `AddOutbox`. Publish a durable event, commit through the unit of work, then assert the owning datastore's `__OutboxMessages` has exactly one row. Also assert `provider.GetRequiredService()` is `OutboxEntityEventTracker`.
+- [ ] **Step 2: Run it, watch it fail** — rows = 0 and tracker = `InMemoryEntityEventTracker`. This is the reproduction.
+
+## Task 2: #15 fix — authoritative outbox registration
+
+**Files:**
+- Modify: `Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs:58-69`
+
+- [ ] **Step 1:** In `AddOutboxProducer`, keep the concrete `TryAddScoped` registrations (`OutboxEventRouter`, `InMemoryTransactionalEventRouter`, `InMemoryEntityEventTracker`). Replace the two interface `TryAdd`s with authoritative remove-then-add so the outbox wins regardless of ordering:
+ ```csharp
+ builder.Services.RemoveAll();
+ builder.Services.AddScoped(sp => sp.GetRequiredService());
+ builder.Services.RemoveAll();
+ builder.Services.AddScoped();
+ ```
+ Add a comment explaining why `TryAdd` is wrong here (defect #15). Idempotent across repeated `AddOutbox` calls (remove-then-add nets one). A later non-outbox `WithPersistence` `TryAdd` will no-op because the outbox registration is present.
+- [ ] **Step 2:** Run Task 1 test → passes (1 row, tracker = `OutboxEntityEventTracker`).
+- [ ] **Step 3:** Run the whole `Examples.EventHandling.Outbox.Tests` + `RCommon.Persistence` unit suites → all green (no regression to happy-path / two-datastore).
+- [ ] **Step 4: Commit.**
+
+## Task 3: #15 fix — routing diagnostic checks the load-bearing tracker
+
+**Files:**
+- Modify: `Src/RCommon.Persistence/Outbox/OutboxRoutingDiagnosticsHostedService.cs`
+- Test: add a diagnostic test (a host where the tracker was overridden warns; a correct host does not)
+
+- [ ] **Step 1:** Write a failing test asserting the diagnostic warns iff the effective `IEntityEventTracker` is not `OutboxEntityEventTracker` (and does **not** false-positive on a correctly-wired outbox host).
+- [ ] **Step 2:** Change the diagnostic to inspect the last `IEntityEventTracker` descriptor (implementation type / factory target) instead of `IEventRouter`. With the Task 2 fix in place a correct host never warns.
+- [ ] **Step 3:** Run → green. **Commit.**
+
+## Task 4: #16 — processor-only host
+
+**Files:**
+- Test: `Examples/EventHandling/Examples.EventHandling.Outbox.Tests/ProcessorOnlyHostDiTests.cs` (create)
+- Modify (pending repro): `Src/RCommon.Persistence/Outbox/OutboxPersistenceBuilderExtensions.cs` (`AddOutboxProcessor`)
+
+- [ ] **Step 1:** Write a DI smoke test: `AddRCommon().WithPersistence(ef => { AddDbContext; SetDefaultDataStore; ef.AddOutboxProcessor(); })` with `BuildServiceProvider(validateScopes: true, validateOnBuild: true)`, then start the host / resolve `IHostedService`s and run one poll batch. Observe the actual failure.
+- [ ] **Step 2:** Based on the observed failure, either (a) have `AddOutboxProcessor` register the shared routing/tracker it needs, or (b) if a pure poller is genuinely self-sufficient and the failure only arises when the processor host also does domain writes, apply the same authoritative tracker registration and document the topology. Prefer the registration fix over docs-only.
+- [ ] **Step 3:** Run → green. **Commit.**
+
+## Task 5: #17 — correct the docs over-claim
+
+**Files:**
+- Modify: `website/docs/api-reference/changelog.mdx` (3.2.0 entry)
+- Modify: `website/docs/api-reference/migration-guide.mdx`
+- Modify: `docs/specs/event-handling/event-handling.md` (AC-18/19/20 status)
+- Modify: `website/versioned_docs/version-3.2.0/api-reference/{changelog,migration-guide}.mdx` (released snapshot)
+
+- [ ] **Step 1:** Remove metrics `Meter`, `IOutboxPayloadProtector`, and deserialization allow-list from the 3.2.0 "Added" list; move them to a "Planned / not yet shipped" note (or delete). Mark AC-18/19/20 in the spec as deferred. **Commit.**
+
+## Task 6: modular multi-datastore outbox example
+
+**Files:**
+- Create: `Examples/EventHandling/Examples.EventHandling.Outbox.Modular/` (project) — module registration extension methods (one per bounded context), each calling `WithPersistence`/`WithEventHandling`; a composition root wiring 2–3 datastores with the native outbox; a `Program` that commits and prints outbox row counts.
+- Create: matching `*.Tests` asserting durable rows persist across all datastores under the modular composition.
+- Add both to the solution.
+
+- [ ] **Step 1:** Build the modular example illustrating exactly the customer's shape (multiple modules, multiple datastores, native `AddOutbox`). Test proves rows persist. **Commit.**
+
+## Task 7: green + finish
+
+- [x] Run full solution build + the event-handling/persistence test suites (fast lane, `Category!=Integration`).
+- [ ] Push branch; open PR. (Deferred — user reviewing commits first.)
+- [ ] Do **not** tag/release until the user approves the PR.
+
+## Verification (2026-07-23)
+
+**Unit (fast lane, `Category!=Integration`) — all green:**
+RCommon.Persistence 453 · RCommon.Core 575 · RCommon.EfCore 158 (+3 skipped) · RCommon.Entities 236 · RCommon.Wolverine.Outbox 9 · RCommon.Dapper 92 · RCommon.Linq2Db 73.
+
+**Examples (whole `Examples.sln`, fast lane) — all green:**
+Outbox 6 (incl. the #15 & #16 repros) · Outbox.Modular 2 · MediatR 1 · NoUnitOfWork 2 · TransactionScript 1 · DomainDrivenDesign 10 · Persistence.Sagas 3 · HR.LeaveManagement 4. Full `Examples.sln` builds. Modular example console app runs clean (1 outbox row per datastore, none leaked).
+
+**Integration (Podman → Postgres/RabbitMQ) — outbox path all green when containers start:**
+`CrossDataStoreOutboxTests` 2/2 · `RecipeTwoBBrokerOutboxTests` 2/2 · `MassTransitOutboxCoordinationSpikeTests` 2/2 · `WolverineOutboxCoordinationSpikeTests` 3/3. Running all four collections at once produced 5 `PostgreSqlFixture.InitializeAsync` container-start timeouts (named-pipe connect) — an environmental 2 GiB-Podman contention issue, not an assertion/DI failure; each class passes when run alone.
+
+**Not run (out of scope for the outbox DI change):** S3 (LocalStack) / Azure (Azurite) blob-storage integration tests — unrelated subsystem, require emulators.
diff --git a/website/docs/api-reference/changelog.mdx b/website/docs/api-reference/changelog.mdx
index b9d6ab3a..0f0b34b7 100644
--- a/website/docs/api-reference/changelog.mdx
+++ b/website/docs/api-reference/changelog.mdx
@@ -12,6 +12,25 @@ The authoritative release history for all RCommon packages is maintained on GitH
## Recent Changes
+### 3.2.1
+
+Outbox DI-registration hardening and documentation accuracy. This is a behavior-only bug-fix release — no public API changes; upgrading from 3.2.0 requires no code changes.
+
+**Fixed**
+
+- **Durable events were silently dropped under modular / multi-datastore composition.** `WithEventTracking` runs at the end of every `WithPersistence` call and registered the in-memory entity-event tracker with `TryAdd`; when a non-outbox module (or a second datastore) was registered before the outbox one, the in-memory tracker was pinned and the outbox's own `TryAdd` no-opped — so every durable event dispatched in-process and was never written to the outbox, with no error. The outbox now registers the entity-event tracker and router **authoritatively** (remove-then-add) in the shared outbox core, so it wins regardless of registration order. If you added a `services.Replace(...)` workaround for this, you can remove it after upgrading.
+- **Processor and combined hosts silently dropped durable events.** Outbox routing was registered only by `AddOutboxProducer`; a host that ran the poller (`AddOutboxProcessor`) and also committed domain entities lost its durable events. Routing now lives in the shared core that both `AddOutboxProducer` and `AddOutboxProcessor` call, so producer, processor, and combined hosts all persist. This also removes the registration path that could surface as an "unable to resolve `InMemoryEntityEventTracker`" DI error.
+- **Outbox routing startup diagnostic inspected the wrong service.** It checked `IEventRouter` (which the core always binds to the in-memory router) and so false-warned on every working outbox host while missing the real failure. It now inspects the load-bearing `IEntityEventTracker`.
+
+**Added**
+
+- **Modular multi-datastore outbox example** (`Examples.EventHandling.Outbox.Modular`): three bounded-context modules, each owning its own datastore and native outbox, composed at one root — with an end-to-end test proving every datastore's durable events persist regardless of module registration order.
+- **Multi-datastore unit-of-work semantics** are now pinned by an integration test and documented in a "One transaction per datastore" note in the [Event Handling Recipes](../event-handling/recipes.mdx#multiple-datastores) guide: an outbox row is atomic with **its own** datastore's state change (local transaction); a single unit of work spanning two datastores promotes to a distributed transaction and **fails loud** (e.g. Postgres `55000: prepared transactions are disabled`); cross-datastore delivery is **eventual** (at-least-once via the poller). Split a multi-datastore operation into a unit of work per datastore.
+
+**Docs**
+
+- Corrected the 3.2.0 changelog and spec, which listed outbox metrics, `IOutboxPayloadProtector`, and the deserialization allow-list as shipped features — they are **planned, not shipped** (see the 3.2.0 "Planned" note below).
+
### 3.2.0
Event-handling and transactional-outbox redesign. This is a **breaking** release: durability is now opt-in per route, in-process domain handlers dispatch *before* commit, and the outbox is datastore-aware end to end. A new recipe-based conceptual guide documents the supported patterns. See the [Event Handling Recipes](../event-handling/recipes.mdx) guide and the [3.2.0 migration section](./migration-guide.mdx#upgrading-to-320-event-handling--outbox-redesign) for the full old→new mapping.
@@ -28,9 +47,6 @@ Event-handling and transactional-outbox redesign. This is a **breaking** release
- **Datastore-aware transactional outbox (B4/U5).** Per-datastore `__OutboxMessages` tables; each row records its target producer(s), so one table can fan out to multiple transports. `OutboxProcessingService` claims and drains every registered outbox datastore. Registering an outbox auto-applies the `OutboxMessage` mapping to that datastore's `RCommonDbContext`, and a startup diagnostic fails loud if a registered outbox datastore's table is missing.
- **Broker-native outbox wrapper (`UseBrokerOutbox`).** Configures MassTransit's EF Core outbox (`AddEntityFrameworkOutbox` + `UseBusOutbox`) against an RCommon datastore's `DbContext`, so business state and broker-outbox rows commit atomically inside the RCommon unit-of-work transaction. Proven by a Postgres/Testcontainers coordination test. *(Wolverine does not support recipe 2b — see Known Issues / use recipe 2a.)*
- **First-class producer/processor topology (AC-21):** `AddOutboxProducer` (store/router/tracker, no hosted poller) and `AddOutboxProcessor` (hosted poller) alongside `AddOutbox` (= both), each datastore-scoped via `OnDataStore(...)`.
-- **Opt-in outbox metrics (AC-18):** a `RCommon.Outbox` `System.Diagnostics.Metrics.Meter` instrumenting per-datastore pending depth, oldest-unprocessed age, relay success/failure, dead-letter rate, and dispatch-queue depth. Additive; the host wires it into its metrics pipeline.
-- **Payload protection hook (AC-19):** an `IOutboxPayloadProtector` seam (`Protect`/`Unprotect`) around payload serialization; default is pass-through (plaintext).
-- **Deserialization allow-list (AC-20):** the outbox serializer resolves only registered event types; an unknown/tampered `EventType` is logged loud and dead-lettered rather than deserialized to an arbitrary type.
- **Five runnable recipe examples with end-to-end tests (AC-16):** RCommon per-datastore outbox (+ 2-datastore variant), broker-as-producer behind the RCommon outbox (MassTransit + Wolverine), broker-native outbox (MassTransit), transaction-script router-added events, no-unit-of-work direct publish, and in-process MediatR.
- **Podman/Testcontainers integration harness** (Postgres + RabbitMQ) proving cross-datastore atomicity, `TransactionScope` enlistment, and the recipe-2b coordination gate.
@@ -50,6 +66,14 @@ Event-handling and transactional-outbox redesign. This is a **breaking** release
- Wolverine's recipe 2b (broker-native outbox) is unsupported by design; the builder's `UseBrokerOutbox` throws `NotSupportedException`. Wolverine users get an atomic outbox via recipe 2a (broker as a producer behind RCommon's own outbox). See [Known Issues](#known-issues).
+**Planned — designed but NOT shipped in 3.2.0**
+
+These items were specified for the redesign (AC-18/19/20) but did **not** ship in 3.2.0. An earlier draft of this changelog listed them under "Added" in error. They remain on the roadmap; do not build against them yet.
+
+- **Opt-in outbox metrics (AC-18):** a `RCommon.Outbox` `System.Diagnostics.Metrics.Meter` for per-datastore pending depth, oldest-unprocessed age, relay success/failure, dead-letter rate, and dispatch-queue depth. Not present in 3.2.0. For now, observe the outbox by querying the `__OutboxMessages` tables (unprocessed count, oldest `CreatedAtUtc`, dead-lettered rows) or by watching the poller's log output.
+- **Payload protection hook (AC-19):** an `IOutboxPayloadProtector` (`Protect`/`Unprotect`) seam around payload serialization. Not present in 3.2.0 — outbox payloads are plaintext within the application-database trust boundary. Applications needing field/payload encryption must handle it above the event model until this ships.
+- **Deserialization allow-list (AC-20):** a serializer that resolves only registered event types and dead-letters unknown/tampered `EventType`s. Not present in 3.2.0; the serializer resolves the type named in the row.
+
### 3.1.3
Outbox silent-failure hardening. Every change is observability-only (new warnings and a startup diagnostic); no method or interface signatures changed and no runtime behavior changed on the success path — fully backward-compatible.
diff --git a/website/docs/api-reference/migration-guide.mdx b/website/docs/api-reference/migration-guide.mdx
index 13ae21b5..86925bf7 100644
--- a/website/docs/api-reference/migration-guide.mdx
+++ b/website/docs/api-reference/migration-guide.mdx
@@ -171,6 +171,14 @@ Two limitations ship with 3.2.0, each with a supported workaround — see [Known
- **Dapper outbox store is SQL Server–only.** On PostgreSQL, use `EFCoreOutboxStore` for the outbox datastore.
- **MediatR `Send()` may not resolve handlers.** Use `Publish()` for in-process MediatR event delivery.
+## Upgrading to 3.2.1
+
+3.2.1 is a behavior-only bug-fix release with **no public API changes** — upgrading from 3.2.0 requires no code changes. It fixes silent outbox data loss in two compositions and clarifies multi-datastore transaction semantics. See the [3.2.1 changelog](./changelog.mdx#321) for the full list.
+
+- **Remove any outbox-routing workaround.** If you worked around durable events not persisting under modular / multi-datastore composition (e.g. a `services.Replace(...)` re-asserting the outbox `IEntityEventTracker`/`IEventRouter`), you can delete it — the outbox now registers those authoritatively and wins regardless of module/datastore registration order.
+- **Processor and combined hosts now persist durable events.** If you had split producer and processor and found a host that ran the poller and also committed domain entities was losing events (or threw "unable to resolve `InMemoryEntityEventTracker`"), that is fixed; outbox routing now lives in the shared core that `AddOutboxProducer` and `AddOutboxProcessor` both call.
+- **Review multi-datastore transaction boundaries.** A single unit of work must not write to more than one datastore — that promotes to a distributed transaction and fails loud (e.g. Postgres `55000: prepared transactions are disabled`). Split a multi-datastore operation into a unit of work per datastore; each commits locally and atomically with its own outbox row, and cross-datastore delivery is eventual via the poller. See the ["One transaction per datastore" note](../event-handling/recipes.mdx#multiple-datastores).
+
## Breaking Change Patterns
The following sections describe the categories of breaking changes that appear in major RCommon releases and how to address them.
diff --git a/website/docs/event-handling/recipes.mdx b/website/docs/event-handling/recipes.mdx
index 7bdf7b74..ed7592ba 100644
--- a/website/docs/event-handling/recipes.mdx
+++ b/website/docs/event-handling/recipes.mdx
@@ -119,6 +119,8 @@ Once an event type is declared durable, it is buffered into the outbox **within
Register an outbox per datastore. An event published `.UseOutbox("Billing")` lands **only** in Billing's outbox — never in Orders'.
+For a full worked version — several bounded-context modules, each owning its own datastore and outbox and composed at one root — see the runnable `Examples.EventHandling.Outbox.Modular` example.
+
```csharp
.WithPersistence(ef =>
{
@@ -137,6 +139,12 @@ flowchart LR
BT -.->|poller| BUSB(("relay"))
```
+:::caution One transaction per datastore — no cross-datastore atomicity
+An outbox row is atomic with **its own** datastore's state change — they share one connection and one local transaction (co-location). There is **no** distributed atomicity **across** datastores. `UnitOfWork` wraps a single ambient `TransactionScope` (`Required`), so if one unit of work writes to a **second** datastore, a second connection enlists and the transaction **promotes to a distributed transaction (2PC/MSDTC)**. Across engines this is the classic failure — e.g. Postgres + SQL Server, where Npgsql issues `PREPARE TRANSACTION` and Postgres rejects it with `55000: prepared transactions are disabled` (and two databases on the *same* engine promote identically; the trigger is the second connection, not the engine pairing).
+
+So when one logical operation touches more than one datastore, **use a unit of work per datastore**. Each commits locally and atomically (state + that datastore's co-located outbox row); cross-datastore delivery is **eventual** (at-least-once via the poller). RCommon never forces cross-datastore 2PC, and it fails loud rather than half-committing — see `MultiDataStoreUnitOfWorkSemanticsTests`.
+:::
+
---
## Recipe 2a — broker as producer behind the RCommon outbox
diff --git a/website/versioned_docs/version-3.2.0/api-reference/changelog.mdx b/website/versioned_docs/version-3.2.0/api-reference/changelog.mdx
index b9d6ab3a..1cd80145 100644
--- a/website/versioned_docs/version-3.2.0/api-reference/changelog.mdx
+++ b/website/versioned_docs/version-3.2.0/api-reference/changelog.mdx
@@ -28,9 +28,6 @@ Event-handling and transactional-outbox redesign. This is a **breaking** release
- **Datastore-aware transactional outbox (B4/U5).** Per-datastore `__OutboxMessages` tables; each row records its target producer(s), so one table can fan out to multiple transports. `OutboxProcessingService` claims and drains every registered outbox datastore. Registering an outbox auto-applies the `OutboxMessage` mapping to that datastore's `RCommonDbContext`, and a startup diagnostic fails loud if a registered outbox datastore's table is missing.
- **Broker-native outbox wrapper (`UseBrokerOutbox`).** Configures MassTransit's EF Core outbox (`AddEntityFrameworkOutbox` + `UseBusOutbox`) against an RCommon datastore's `DbContext`, so business state and broker-outbox rows commit atomically inside the RCommon unit-of-work transaction. Proven by a Postgres/Testcontainers coordination test. *(Wolverine does not support recipe 2b — see Known Issues / use recipe 2a.)*
- **First-class producer/processor topology (AC-21):** `AddOutboxProducer` (store/router/tracker, no hosted poller) and `AddOutboxProcessor` (hosted poller) alongside `AddOutbox` (= both), each datastore-scoped via `OnDataStore(...)`.
-- **Opt-in outbox metrics (AC-18):** a `RCommon.Outbox` `System.Diagnostics.Metrics.Meter` instrumenting per-datastore pending depth, oldest-unprocessed age, relay success/failure, dead-letter rate, and dispatch-queue depth. Additive; the host wires it into its metrics pipeline.
-- **Payload protection hook (AC-19):** an `IOutboxPayloadProtector` seam (`Protect`/`Unprotect`) around payload serialization; default is pass-through (plaintext).
-- **Deserialization allow-list (AC-20):** the outbox serializer resolves only registered event types; an unknown/tampered `EventType` is logged loud and dead-lettered rather than deserialized to an arbitrary type.
- **Five runnable recipe examples with end-to-end tests (AC-16):** RCommon per-datastore outbox (+ 2-datastore variant), broker-as-producer behind the RCommon outbox (MassTransit + Wolverine), broker-native outbox (MassTransit), transaction-script router-added events, no-unit-of-work direct publish, and in-process MediatR.
- **Podman/Testcontainers integration harness** (Postgres + RabbitMQ) proving cross-datastore atomicity, `TransactionScope` enlistment, and the recipe-2b coordination gate.
@@ -50,6 +47,14 @@ Event-handling and transactional-outbox redesign. This is a **breaking** release
- Wolverine's recipe 2b (broker-native outbox) is unsupported by design; the builder's `UseBrokerOutbox` throws `NotSupportedException`. Wolverine users get an atomic outbox via recipe 2a (broker as a producer behind RCommon's own outbox). See [Known Issues](#known-issues).
+**Planned — designed but NOT shipped in 3.2.0**
+
+These items were specified for the redesign (AC-18/19/20) but did **not** ship in 3.2.0. An earlier draft of this changelog listed them under "Added" in error. They remain on the roadmap; do not build against them yet.
+
+- **Opt-in outbox metrics (AC-18):** a `RCommon.Outbox` `System.Diagnostics.Metrics.Meter` for per-datastore pending depth, oldest-unprocessed age, relay success/failure, dead-letter rate, and dispatch-queue depth. Not present in 3.2.0. For now, observe the outbox by querying the `__OutboxMessages` tables (unprocessed count, oldest `CreatedAtUtc`, dead-lettered rows) or by watching the poller's log output.
+- **Payload protection hook (AC-19):** an `IOutboxPayloadProtector` (`Protect`/`Unprotect`) seam around payload serialization. Not present in 3.2.0 — outbox payloads are plaintext within the application-database trust boundary. Applications needing field/payload encryption must handle it above the event model until this ships.
+- **Deserialization allow-list (AC-20):** a serializer that resolves only registered event types and dead-letters unknown/tampered `EventType`s. Not present in 3.2.0; the serializer resolves the type named in the row.
+
### 3.1.3
Outbox silent-failure hardening. Every change is observability-only (new warnings and a startup diagnostic); no method or interface signatures changed and no runtime behavior changed on the success path — fully backward-compatible.
diff --git a/website/versioned_docs/version-3.2.0/event-handling/recipes.mdx b/website/versioned_docs/version-3.2.0/event-handling/recipes.mdx
index 7bdf7b74..62c256f2 100644
--- a/website/versioned_docs/version-3.2.0/event-handling/recipes.mdx
+++ b/website/versioned_docs/version-3.2.0/event-handling/recipes.mdx
@@ -137,6 +137,12 @@ flowchart LR
BT -.->|poller| BUSB(("relay"))
```
+:::caution One transaction per datastore — no cross-datastore atomicity
+An outbox row is atomic with **its own** datastore's state change — they share one connection and one local transaction (co-location). There is **no** distributed atomicity **across** datastores. `UnitOfWork` wraps a single ambient `TransactionScope` (`Required`), so if one unit of work writes to a **second** datastore, a second connection enlists and the transaction **promotes to a distributed transaction (2PC/MSDTC)**. Across engines this is the classic failure — e.g. Postgres + SQL Server, where Npgsql issues `PREPARE TRANSACTION` and Postgres rejects it with `55000: prepared transactions are disabled` (and two databases on the *same* engine promote identically; the trigger is the second connection, not the engine pairing).
+
+So when one logical operation touches more than one datastore, **use a unit of work per datastore**. Each commits locally and atomically (state + that datastore's co-located outbox row); cross-datastore delivery is **eventual** (at-least-once via the poller). RCommon never forces cross-datastore 2PC, and it fails loud rather than half-committing.
+:::
+
---
## Recipe 2a — broker as producer behind the RCommon outbox