↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Architecture Decision Record

ADR-021: Consumer-Side Inbox for Integration-Event Idempotency

Status

Accepted (2026-06-09; adoption reviewed 2026-07-15, inventory refreshed 2026-10-01). Revised 2026-08-18 (the inbox stays opt-in, but being off is no longer silent: a broker-connected host running NoOpInboxStore logs a startup warning, MessageBus:EnableInbox=true becomes the stated recommendation for any such host, and the InboxMessages entity is confirmed to be part of the relational model unconditionally. See the Revision (2026-08-18) at the end). Revised 2026-08-26 (the inbox is no longer opt-in for a broker transport: it resolves ON unless a host explicitly turns it off, and its row is staged into the handler's own unit of work so it commits atomically with the handler's mutations. See the Revision (2026-08-26) at the end). See also ADR-100 (2026-08-29, v1.170.0), which applies this record's three-valued resolution rule to the producer side: MessageBus:EnableOutbox is bool? and resolves from the transport the same way EnableInbox does here. The two settings read identically only in that resolution direction. In the explicit-false direction they part: under a broker transport EnableInbox=false is honoured (the host gets NoOpInboxStore plus the startup warning), whereas EnableOutbox=false is refused at registration, because a broker deployment publishes exclusively through the outbox and has no other path (EnsureOutboxAvailableForProvider throws in MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:202-209, stated on the setting itself at .../Messaging/MessageBusSettings.cs:161-164). Nothing about the inbox contract changes. Revised 2026-09-07 (queue and endpoint names are prefixed per application by default, so a consumer either pins its pre-upgrade prefix or drains its old queues on cutover). Revised 2026-09-11 (a broker-delivered event now restores the publisher's tenant, principal and correlation id from MMCA-* message headers before the inbox is touched, because the inbox store resolves its context through the same scope; a message carrying no headers leaves the consumer's defaults untouched; see the Revision (2026-09-11) at the end). Revised 2026-10-06 (the inbox row commits atomically with a handler's mutations only for a handler that saves on the consumer's own scope; the framework's ScopedIntegrationEventHandlerBase handlers save on a scope of their own, so for them the row is written by CompleteAsync after they succeed and the crash window stays open; see the Revision (2026-10-06) at the end). Revised 2026-10-07: anchors refreshed after the v1.233.0 release.

Context

ADR-003 makes integration-event delivery at-least-once: the outbox guarantees a published event is not lost, and the MassTransit broker redelivers on consumer failure. At-least-once means a consumer can legitimately see the same event more than once (broker redelivery after a transient handler failure, a redeploy mid-consume, or a lost ack). ADR-003's answer was "domain-event handlers must be idempotent," but that pushes the dedup burden onto every handler author and is easy to get wrong. We wanted a single, reusable place that recognizes an already-processed integration event and skips it, without changing how handlers are written.

This is the broker-consumer sibling of two idempotency concerns the framework already records: ADR-003 (the producer/outbox side plus handler idempotency) and ADR-017 (the inbound HTTP edge, deduping client retries). The inbox is the third leg: deduping broker redeliveries at the consume edge.

Decision

Add an opt-in inbox that records each successfully-processed integration event by its MessageId and skips redeliveries.

  • Every event carries a MessageId. BaseDomainEvent stamps a unique MessageId; it is the dedup key (the same id the outbox serializes and the broker carries).
  • IInboxStore with two implementations. EfInboxStore (active) records processed messages in an InboxMessages table; NoOpInboxStore never dedups. The switch is the MessageBus:EnableInbox flag (default false): AddBrokerMessaging registers EfInboxStore when set and NoOpInboxStore otherwise. (Superseded by the Revision (2026-08-26): the setting is now bool? and resolves ON for a broker transport when unset, so NoOpInboxStore is reached only by an explicit opt-out.)
  • Check before, record after. The generic IntegrationEventConsumer<TEvent> calls AlreadyProcessedAsync(MessageId) first and skips the handlers (acking the message) when it is a duplicate; it calls MarkProcessedAsync(MessageId, eventType) only after all handlers succeed. A handler that throws rethrows, so MassTransit applies its retry/dead-letter policy and the message stays un-recorded (eligible for redelivery). (Superseded by the Revision (2026-08-26): the consume path is now TryBeginAsync -> handlers -> CompleteAsync, with the row STAGED at the start and persisted by whichever save comes first.)
  • The inbox lives in the consumer's own database. Rows are written to the host's outbox data source (Outbox:DataSource / Outbox:DatabaseName), so each service dedups in its own database, consistent with database-per-service (ADR-006). Every relational source gets an InboxMessages table; Cosmos hosts skip it.
  • A unique index is the concurrency guard. IX_InboxMessages_MessageId is unique; a concurrent duplicate that races past the AlreadyProcessedAsync check fails its insert with DbUpdateException, which EfInboxStore swallows as "already processed."
  • Bounded retention. OutboxCleanupService purges inbox rows older than Outbox:RetentionDays alongside outbox rows (gated on EnableInbox), so the table does not grow forever (ADR-005).

The delivery guarantee is therefore at-least-once-with-dedup, not exactly-once: a crash between a handler's commit and the inbox write reprocesses the event exactly once more, so handlers must still be idempotent for that narrow window. The inbox removes the routine-duplicate burden; it does not make handlers free to be non-idempotent. (Narrowed by the Revision (2026-08-26), and only partly: a handler that saves through the consumer's own scope to the same physical source commits the inbox row in its own transaction, so that window is closed for it. The framework's persisting handlers do not take that path: they derive from ScopedIntegrationEventHandlerBase and open their own scope per delivery (MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:14-16, scope created at :51), so their saves never carry the staged row, which CompleteAsync writes only after every handler has succeeded (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumer.cs:77-81, :123). The window therefore remains open for them, as it does for a handler that writes nothing or writes to a different source, and handlers must still be idempotent.)

In production EnableInbox: true is set explicitly on all four ADC service hosts (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:56, MMCA.ADC.Conference.Service/appsettings.json:48, MMCA.ADC.Engagement.Service/appsettings.json:68, MMCA.ADC.Notification.Service/appsettings.json:82) and on all three Store service hosts (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/appsettings.json:58, MMCA.Store.Catalog.Service/appsettings.json:54, MMCA.Store.Identity.Service/appsettings.json:48), so every deployed service host states its posture rather than resting on the transport default the Revision (2026-08-26) introduced. Where the InboxMessages table comes from differs by repo: each of the four ADC per-service migration projects carries a dedicated AddInboxMessages migration, whereas each Store per-service project creates the table and its unique IX_InboxMessages_MessageId index inside its single InitialCreate migration (MMCA.Store/Source/Hosting/MMCA.Store.Migrations.SqlServer.Sales/Migrations/20260621192808_InitialCreate.cs:21,179, MMCA.Store.Migrations.SqlServer.Catalog/Migrations/20260621192800_InitialCreate.cs:48,213, MMCA.Store.Migrations.SqlServer.Identity/Migrations/20260621192816_InitialCreate.cs:49,119), because those per-service projects postdate the frozen combined-archive lineage that added the ADC migration. Adoption inventory as of 2026-10-01: five of the seven service hosts consume from the broker and so use their inbox for real, three in ADC and two in Store. ADC Identity consumes SpeakerLinkedToUser and SpeakerUnlinkedFromUser (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:330-331), ADC Conference consumes UserRegistered and UserDeleted (MMCA.ADC.Conference.Service/ConferenceBrokerConsumers.cs:34-35) and chains a third broker consumer beside them, RegisterOutputCacheEvictionConsumer() (MMCA.ADC.Conference.Service/ConferenceBrokerConsumers.cs:37), and ADC Engagement consumes four events, AttendeeCheckedIn, SessionFeedbackSubmitted, EventFeedbackSubmitted and UserDeleted (MMCA.ADC.Engagement.Service/Program.cs:293-296), the first of which is ADC's first self-consumption over the broker: Engagement publishes AttendeeCheckedIn and consumes it back, which is precisely the shape a redelivery would double-count, so the inbox is load-bearing there rather than decorative. Store Sales consumes three events, ProductVariantChanged, ProductInfoChanged and CustomerErased (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:261, :266, :271), and Store Catalog consumes OrderFulfilled and CustomerErased plus the output-cache eviction event (MMCA.Store.Catalog.Service/Program.cs:274, :283, :290), which makes Catalog both publisher and consumer of that eviction event and therefore exactly the redelivery-doubles-work shape the inbox guards. The remaining two hosts (ADC Notification and Store Identity) carry EnableInbox: true and the table while registering no consumer, so their inboxes are provisioned and unused, which is functionally harmless: the flag costs one scoped EfInboxStore registration and the table stays empty until one of them starts consuming. The audit condition the Trade-offs below state (no broker-consuming service lacks the flag) holds in both repos, and no host in either repo now leaves the setting to the transport default.

Rationale

  • Dedup once, not in every handler. A single consume-edge check turns "every handler author must remember to be idempotent against redelivery" into a framework guarantee for the common case, the same invariant-over-discipline posture the framework prefers (ADR-015).
  • Record-after-success is the correct ordering. Marking processed only after handlers succeed means a failure leaves the message redeliverable; marking before would risk dropping an event whose handler then failed.
  • Physical isolation reuses database-per-service. Putting the inbox in the consumer's own outbox database needs no new infrastructure and keeps each service self-contained, with no shared dedup store to race on.
  • Off for in-process keeps the monolith simple. In-process dispatch (ADR-003) never redelivers, so a single-process or broker-less deployment needs no inbox: IsInboxEnabled resolves OFF for it (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:141) and AddBrokerMessaging returns before registering any inbox store (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:52-55), so it costs nothing. NoOpInboxStore is registered only for a broker host that explicitly opts out (:118).

Trade-offs

  • Not exactly-once. The crash-after-handler-before-inbox window reprocesses once, so handlers must stay idempotent for it; the inbox narrows the duplicate window, it does not close it. (The Revision (2026-08-26) closes it only for a handler that saves through the consumer's own scope to the same physical source. It stays open otherwise, including for every framework handler built on ScopedIntegrationEventHandlerBase, which saves on a scope of its own; see the Revision (2026-10-06).)
  • Opt-in per service. A broker-consuming service that forgets EnableInbox gets no dedup (and no InboxMessages table), the same audit-the-inventory caveat as ADR-005 / ADR-017 / ADR-020. Enabling it also requires the migration that creates the table. (Superseded: the Revision (2026-08-18) removed the migration claim, and the Revision (2026-08-26) removed the opt-in itself for broker hosts, so forgetting the flag is no longer a way to lose dedup.)
  • A second housekeeping table. Each consumer database carries an InboxMessages table and its retention purge, in addition to the outbox.
  • Dedup is keyed on MessageId, not payload. Dedup is per-message-identity (the intended granularity); a producer that re-published the same business action under a new MessageId would not be deduped by the inbox.

Revision (2026-09-07)

Inbox idempotency is unchanged. The names the consumer endpoints take are not (SEC-Common-53). MessageBus:EndpointPrefix left unset used to mean "no prefix"; it now defaults to the resolved application namespace (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:66, resolved through ApplicationNamespace.Resolve at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:793; the resolver itself is at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Configuration/ApplicationNamespace.cs:53 and takes Application:Namespace, falling back to the host application name).

This matters here because the inbox is keyed per consumer endpoint. A rename moves the endpoint, so the cutover rule is explicit: a consumer that must keep its current endpoint names pins the pre-upgrade value (Application:Namespace or MessageBus:EndpointPrefix) before upgrading, and a consumer that accepts the new names drains the old queues, because messages already sitting under the old endpoint name have no subscriber afterwards. The inbox rows themselves are unaffected: they record the message id, and a redelivery under the new endpoint is still recognised as a duplicate.

ADR-003 (the outbox and at-least-once delivery whose consumer side this deduplicates; handler idempotency is still required for the crash window), ADR-006 (the inbox lives in the consumer's own database), ADR-005 (OutboxCleanupService bounds inbox retention too), ADR-017 (the inbound-HTTP-edge idempotency this mirrors at the broker-consume edge), ADR-066 (the transport selection whose non-InProcess providers are exactly the hosts the new startup warning fires in), ADR-087 (second-level redelivery, which can now re-run a handler an hour after the original attempt: the inbox and every idempotent handler must hold across that gap, not only across a retry burst).

Revision (2026-08-18)

The decision is unchanged: the inbox is still opt-in and NoOpInboxStore is still the default. What changed is that the default is now loud.

  1. A broker-connected host with no inbox says so at startup. AddBrokerMessaging returns early for MessageBusProvider.InProcess (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:755-758), which is what scopes this to hosts that actually talk to a broker: in-process dispatch never redelivers, so there is nothing to warn about. Past that early return, the EnableInbox branch (:798-809) registers EfInboxStore as scoped (:800) when the flag is set, and on the else branch registers NoOpInboxStore as a singleton (:804) plus an IHostedService whose only job is to emit one Warning line naming the consequence and the fix (:809). The service is InboxDisabledWarningService (.../Persistence/Inbox/InboxDisabledWarningService.cs:20-21), and it evaluates nothing at runtime: StartAsync logs unconditionally (:24-28), because the condition was already decided by which DI branch registered it. Its message text is at :33-36. The type is internal, so it is a framework behavior rather than a public extension point.
  2. MessageBus:EnableInbox=true is now the stated recommendation, not a neutral option. The setting is still bool with no initializer, so the default is still false, but its own documentation now says "RECOMMENDED true for any broker-connected host". (Superseded by the Revision (2026-08-26): the property is bool? with no initializer (.../Messaging/MessageBusSettings.cs:117), and its documentation now states the transport-driven resolution rather than a recommendation (:91-116).) The Trade-offs entry above ("a broker-consuming service that forgets EnableInbox gets no dedup") therefore keeps its substance and loses its silence: the inventory audit it asks for is now performed by the host at every boot.
  3. The InboxMessages table is part of the relational model unconditionally. ApplicationDbContext.OnModelCreating calls ConfigureInbox(modelBuilder) with no flag check (.../Persistence/DbContexts/ApplicationDbContext.cs:347, body at :565-584, including the unique IX_InboxMessages_MessageId at :576-578), and it is configured inline in the base context rather than as an IEntityTypeConfiguration. The same configuration carries a second, non-unique index, IX_InboxMessages_ProcessedOn (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:730-731), which exists purely for the retention path: the unique index is keyed on MessageId, so the age-based purge (OutboxCleanupService.PurgeInbox, the "Bounded retention" bullet above) had nothing to seek on and scanned the table. SQLServerDbContext and SqliteDbContext reach it through base.OnModelCreating; CosmosDbContext deliberately does not call the base (CosmosDbContext.cs:89, documented at ApplicationDbContext.cs:567-568), so the guarantee is relational engines only, consistent with the "Cosmos hosts skip it" statement in the Decision above.

So the Trade-offs entry above overstated the cost of enabling. It said that enabling the inbox "also requires the migration that creates the table". On a relational host that has applied the standard migrations, it does not: the table is part of the shared relational model those migrations already create, so flipping EnableInbox is a configuration change and a restart with no schema work. Every service enumerated in the Decision above is past that point already (ADC's four per-service migration projects each carry an AddInboxMessages migration; Store's per-service projects create the table in InitialCreate). The settings documentation says the same thing (MessageBusSettings.cs:94-97), and carved out the false default for an existing host that has not migrated the table yet (that carve-out is now the explicit EnableInbox=false opt-out documented at :110-115, per the Revision (2026-08-26)). Cosmos hosts remain the exception, as the Decision above already states.

Revision (2026-08-26)

Two changes, and the first one reverses this record's central choice. The inbox is no longer opt-in where redelivery is possible, and its row is no longer a separate write.

  1. EnableInbox becomes three-valued, and unset resolves ON for a broker. MessageBusSettings.EnableInbox is now bool? with no initializer (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:117), and every framework component reads the resolved posture instead: IsInboxEnabled => EnableInbox ?? Provider != MessageBusProvider.InProcess (:125). An explicit value still wins in both directions (:110-115); left unset the transport decides, ON for RabbitMQ and Azure Service Bus, OFF for the in-process provider that has no redelivery to dedup (:99-102). The reason the default flipped is written where the setting lives: broker delivery is at-least-once by contract, so an ack lost to a network blip, a redelivery after a lease expiry, or an outbox row republished after a crash all hand the same event to the same handlers twice, and with the inbox off each of those becomes a duplicate side effect (a second email, a second charge attempt, a double decrement) unless every handler happens to be idempotent on its own (:103-108). The registration branch is unchanged in shape and only its condition moved: AddBrokerMessaging still returns early for MessageBusProvider.InProcess (.../MMCA.Common.Infrastructure/DependencyInjection.cs:755), so this scopes to broker hosts, and past it settings.IsInboxEnabled (:798) selects the scoped EfInboxStore (:800) or the singleton NoOpInboxStore plus the startup-warning hosted service (:804, :809). Reaching InboxDisabledWarningService therefore now means a deliberate opt-out rather than an unnoticed default, and its Warning says so, naming the setting, the consequence and the fact that the InboxMessages table is already part of the model (.../Persistence/Inbox/InboxDisabledWarningService.cs:13-16, message text at :35). The Trade-offs entry "a broker-consuming service that forgets EnableInbox gets no dedup" no longer describes a reachable state: forgetting the flag now yields dedup, and only writing false removes it.
  2. The inbox row is staged into the handler's own unit of work. The consume path is TryBeginAsync -> handlers -> CompleteAsync, with Abandon on the failure branch (.../Persistence/Inbox/IInboxStore.cs:9-14); all three are default interface members over the original AlreadyProcessedAsync/MarkProcessedAsync pair (:38-39, :48-49, :63), so NoOpInboxStore and any hand-written implementation keep working unchanged. EfInboxStore.TryBeginAsync re-checks and then stages an InboxMessage into the same scoped ApplicationDbContext the handlers write through, tracked in a small per-message dictionary (.../Persistence/Inbox/EfInboxStore.cs:61-68, staging at :116-127, the dictionary and its one-entry-in-practice rationale at :44-49). A handler's own SaveChangesAsync therefore commits the inbox row in the same transaction as its mutations, which closes the window this record's Decision called out: a crash after the handler committed can no longer reprocess work whose save carried the row. CompleteAsync then writes only if nothing else did, keying on the entry still being Added (:71-84), and falls back to the old write-after-handlers path for a caller that skipped TryBeginAsync (:86-88), which is also the path an event whose handlers write nothing takes. IntegrationEventConsumer<TEvent> is where the three calls sit: the duplicate check and skip (.../Messaging/Consumers/IntegrationEventConsumer.cs:54-58), the handler loop, and the final CompleteAsync (:95).
    • The failure path discards the staged row first. A throwing handler triggers inbox.Abandon(messageId) before the rethrow that lets MassTransit apply its retry policy (IntegrationEventConsumer.cs:74, rethrow at :81). Abandon detaches a still-Added entry rather than leaving it staged, because the context is cached for the whole scope and a surviving Added row would be re-attempted by any later save on it (EfInboxStore.cs:106-109). The one case this design loses to a pure after-the-fact inbox is made loud rather than silent: when an earlier handler already committed the row and a later handler then fails, the redelivery is skipped as a duplicate, so the handlers that had not run never will, and Abandon returns false after logging a Warning saying exactly that (:97-104, message at :169-172).
    • Unique-index duplicate absorption is preserved, on both saves. A concurrent duplicate that races past the check fails its insert, and SaveStagedAsync detaches the rejected entry, then re-queries rather than sniffing provider-specific error codes (so the check holds for SQL Server and SQLite alike) and absorbs the rejection only when the row really is there, rethrowing anything else so a genuine write failure cannot ack an unrecorded message (EfInboxStore.cs:140-157). When it is the handler's save that hits the index, the DbUpdateException surfaces to the handler, its mutations roll back, and the broker redelivers into the skip path (:30-36).
    • Atomicity holds where the handler writes to the same physical source the store resolves (the Outbox:DataSource / Outbox:DatabaseName pair, which is the single database of a monolith and of a service that owns one). A handler writing to a different source is back to two transactions and its row is persisted by CompleteAsync, so delivery there stays at-least-once, which is the contract handlers are written against anyway (:23-29).

What this record still says is right. Dedup is keyed on MessageId, the rows live in the consumer's own database, the unique index is the concurrency guard, retention rides OutboxCleanupService, and handlers must stay idempotent: the crash window is closed only for a handler whose own save carried the row.

Revision (2026-09-11)

Dedup semantics are unchanged: same MessageId key, same TryBeginAsync to handlers to CompleteAsync path, same staged row, same unique index as the race guard. What changed is what the scope looks like while that path runs, and the answer used to be "nothing": a broker-delivered event was consumed with no principal, no tenant and no correlation id, so the consumer half of the hop lost the same context the producer half did (see ADR-003's Revision (2026-09-11)).

The publisher stamps four headers. BrokerMessageBus (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMessageBus.cs:43) captures the ambient context once per publish (:54, capture at :88) and writes MMCA-Tenant-Id (:94), MMCA-User-Id (:100), MMCA-User-Roles (:107) and MMCA-Correlation-Id (:112) onto the send context (:123). The names are constants on the public MessageHeaders (MMCA.Common/Source/Core/MMCA.Common.Shared/Messaging/MessageHeaders.cs:25, :28, :31, :34), so a consumer outside this framework can read them without re-deriving the strings. Only values that are present are written.

The consumer restores them before the inbox check, not after. IntegrationEventConsumer<TEvent> calls ConsumerOriginRestore.Apply as its first act (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumer.cs:62-:64, the reason stated at :58), ahead of the inbox name resolution at :70 and the TryBeginAsync call at :81. The order is load-bearing rather than cosmetic: under database-per-tenant (ADR-073) the inbox store resolves its context through the same scope, so a tenant restored after the inbox check would have the dedup row read and written in the wrong database. UpcastingIntegrationEventConsumer<TEvent> does the same in the same position (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/UpcastingIntegrationEventConsumer.cs:67, ahead of its own inbox path at :75), so a message arriving on a retired contract (ADR-090) is restored exactly like one arriving on the current contract.

What the restore does. ConsumerOriginRestore.Apply (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/ConsumerOriginRestore.cs:34) reads the four headers and hands them to the shared AmbientOrigin.Restore (:49-:55, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/AmbientOrigin.cs:127), which rebuilds the captured principal onto the scope and sets the correlation id, stamping the identity with the authentication type IntegrationEvent (IntegrationEventConsumer.cs:49). The user id is transported as text and parsed back rather than read as a typed header (ConsumerOriginRestore.cs:41-:47), because a broker is free to widen an integer header and a parse of the canonical form is the one reading that behaves the same on every transport; a malformed value yields no identity rather than failing the consume. The tenant is set only when the scope has not already resolved a different one (AmbientOrigin.cs:137-:143), the same single-valued-scope rule the outbox restore obeys.

An older publisher keeps working. A message carrying none of these headers leaves the consuming scope's defaults untouched, so an event published by a service still on an earlier package is consumed exactly as it is today. On the type level the two consumers differ. IntegrationEventConsumer<TEvent> gained one optional IServiceProvider parameter (IntegrationEventConsumer.cs:42, null-guarded before the restore at :62); omitting it restores nothing, which is the pre-upgrade behaviour, and the container fills it in for a host that registers the consumers the normal way. UpcastingIntegrationEventConsumer<TEvent> takes the provider as a required, non-nullable parameter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/UpcastingIntegrationEventConsumer.cs:34), because it already needed one before this revision: handler resolution there is non-generic, since the terminal contract type is only known once the upcasters have run, so it resolves handlers through the provider at :108. There is nothing to omit on that type, and its restore always runs.

Revision (2026-10-01)

No decision changed. The consumer inventory did: ADC Conference now consumes UserDeleted beside UserRegistered (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:411-412), so the Decision lists it; the count of broker-consuming hosts (five of seven) is unchanged. The Rationale bullet that still called NoOpInboxStore the default is corrected to the resolution the Revision (2026-08-26) introduced: an in-process host registers no inbox store at all (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:51-54), and a broker host gets EfInboxStore unless it opts out (:107-117). The messaging registration now lives in DependencyInjection.Messaging.cs, so the Status anchor for EnsureOutboxAvailableForProvider moves to :196-203, and the per-host EnableInbox and consumer-registration citations for ADC and Store are refreshed. Two notes on the Revision (2026-09-07), whose text is left as recorded: the inbox is keyed on MessageId alone (unique IX_InboxMessages_MessageId, .../Persistence/DbContexts/ApplicationDbContext.cs:730-732), not per consumer endpoint; and the opt-out that keeps the pre-upgrade, unprefixed endpoint names is MessageBus:PreserveDefaultEndpointNames=true (.../Messaging/MessageBusSettings.cs:75-83, read at DependencyInjection.Messaging.cs:78), because an unset or blank EndpointPrefix resolves to the application namespace (:80-82) and so cannot express "no prefix". Older Revision sections keep their original anchors.

Revision (2026-10-06)

No decision changed; one claim is narrowed to what the code does.

  • The same-transaction claim holds only for a handler on the consumer's own scope. The Revision (2026-08-26) and the Status, Decision and Trade-offs text that summarised it said a handler's own SaveChangesAsync commits the staged inbox row with its mutations. The framework's persisting handlers are singletons deriving from ScopedIntegrationEventHandlerBase, which open their own scope per delivery (MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:14-16, :51), so their saves never carry the row; CompleteAsync writes it after every handler succeeds (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumer.cs:77-81, :123), and the crash window between a handler's commit and that write stays open for them. The Status, the Decision's narrowing note and the Trade-offs entry now say so; the Revision (2026-08-26) text is left as recorded.
  • Consumer registrations moved. ADC Conference registers its broker consumers in MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/ConferenceBrokerConsumers.cs:34-37 rather than in Program.cs, so the Decision cites that file.
  • Current locations for facts recorded in older Revisions. EnsureOutboxAvailableForProvider is at DependencyInjection.Messaging.cs:202-209 (called at :60); the in-process early return at :52-55, EfInboxStore at :110, NoOpInboxStore at :118, PreserveDefaultEndpointNames read at :79 and ApplicationNamespace.Resolve at :82. MessageBusSettings.EnableInbox is at .../Messaging/MessageBusSettings.cs:133, IsInboxEnabled at :141, EndpointPrefix at :73. In IntegrationEventConsumer.cs the origin restore is at :64-66, the inbox name at :71, TryBeginAsync and the duplicate skip at :82-86, Abandon at :102, the rethrow at :109. In .../Persistence/Inbox/EfInboxStore.cs, TryBeginAsync is at :62, CompleteAsync at :72, Abandon at :93 and SaveStagedAsync at :130 (its DbUpdateException catch at :141). ApplicationDbContext calls ConfigureInbox at .../Persistence/DbContexts/ApplicationDbContext.cs:426 and defines it at :721 (IX_InboxMessages_MessageId at :729, IX_InboxMessages_ProcessedOn at :734). ConsumerOriginRestore.Apply is at .../Messaging/Consumers/ConsumerOriginRestore.cs:35 and calls AmbientOrigin.Restore (.../Context/AmbientOrigin.cs:156) at :50. The DependencyInjection.cs anchors in the Revisions (2026-08-18) and (2026-09-07) are historical: that registration now lives in DependencyInjection.Messaging.cs.
  • Anchors in the live sections (Status, Decision, Rationale) were re-verified against current source.

Revision (2026-10-07)

Re-verified against current source. No decision changed: the inbox still resolves ON for a broker transport, the row is still staged by TryBeginAsync and closed out by CompleteAsync, the unique IX_InboxMessages_MessageId index is still the race guard, and the consumer inventory (five of the seven service hosts consume from the broker, every host sets EnableInbox: true) is unchanged. One live-section anchor moved; older Revision sections keep their original anchors.

  1. Anchors re-verified against current source: ADC Engagement registers its four consumers (AttendeeCheckedIn, SessionFeedbackSubmitted, EventFeedbackSubmitted, UserDeleted, same set and order) at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:293-296, so the Decision cites that range. The other live anchors hold as written: DependencyInjection.Messaging.cs:202-209, :52-55 and :118; .../Messaging/MessageBusSettings.cs:141; IntegrationEventConsumer.cs:77-81 and :123; ScopedIntegrationEventHandlerBase.cs:14-16 and :51; the ADC Identity, ADC Conference, Store Sales and Store Catalog consumer registrations; and the seven EnableInbox appsettings lines.