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.BaseDomainEventstamps a uniqueMessageId; it is the dedup key (the same id the outbox serializes and the broker carries). IInboxStorewith two implementations.EfInboxStore(active) records processed messages in anInboxMessagestable;NoOpInboxStorenever dedups. The switch is theMessageBus:EnableInboxflag (defaultfalse):AddBrokerMessagingregistersEfInboxStorewhen set andNoOpInboxStoreotherwise. (Superseded by the Revision (2026-08-26): the setting is nowbool?and resolves ON for a broker transport when unset, soNoOpInboxStoreis reached only by an explicit opt-out.)- Check before, record after. The generic
IntegrationEventConsumer<TEvent>callsAlreadyProcessedAsync(MessageId)first and skips the handlers (acking the message) when it is a duplicate; it callsMarkProcessedAsync(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 nowTryBeginAsync-> 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 anInboxMessagestable; Cosmos hosts skip it. - A unique index is the concurrency guard.
IX_InboxMessages_MessageIdis unique; a concurrent duplicate that races past theAlreadyProcessedAsynccheck fails its insert withDbUpdateException, whichEfInboxStoreswallows as "already processed." - Bounded retention.
OutboxCleanupServicepurges inbox rows older thanOutbox:RetentionDaysalongside outbox rows (gated onEnableInbox), 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:
IsInboxEnabledresolves OFF for it (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/MessageBusSettings.cs:141) andAddBrokerMessagingreturns before registering any inbox store (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:52-55), so it costs nothing.NoOpInboxStoreis 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
EnableInboxgets no dedup (and noInboxMessagestable), 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
InboxMessagestable 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 newMessageIdwould 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.
Related
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.
- A broker-connected host with no inbox says so at startup.
AddBrokerMessagingreturns early forMessageBusProvider.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, theEnableInboxbranch (:798-809) registersEfInboxStoreas scoped (:800) when the flag is set, and on theelsebranch registersNoOpInboxStoreas a singleton (:804) plus anIHostedServicewhose only job is to emit oneWarningline naming the consequence and the fix (:809). The service isInboxDisabledWarningService(.../Persistence/Inbox/InboxDisabledWarningService.cs:20-21), and it evaluates nothing at runtime:StartAsynclogs unconditionally (:24-28), because the condition was already decided by which DI branch registered it. Its message text is at:33-36. The type isinternal, so it is a framework behavior rather than a public extension point. MessageBus:EnableInbox=trueis now the stated recommendation, not a neutral option. The setting is stillboolwith no initializer, so the default is stillfalse, but its own documentation now says "RECOMMENDED true for any broker-connected host". (Superseded by the Revision (2026-08-26): the property isbool?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 forgetsEnableInboxgets 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.- The
InboxMessagestable is part of the relational model unconditionally.ApplicationDbContext.OnModelCreatingcallsConfigureInbox(modelBuilder)with no flag check (.../Persistence/DbContexts/ApplicationDbContext.cs:347, body at:565-584, including the uniqueIX_InboxMessages_MessageIdat:576-578), and it is configured inline in the base context rather than as anIEntityTypeConfiguration. 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 onMessageId, so the age-based purge (OutboxCleanupService.PurgeInbox, the "Bounded retention" bullet above) had nothing to seek on and scanned the table.SQLServerDbContextandSqliteDbContextreach it throughbase.OnModelCreating;CosmosDbContextdeliberately does not call the base (CosmosDbContext.cs:89, documented atApplicationDbContext.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.
EnableInboxbecomes three-valued, and unset resolves ON for a broker.MessageBusSettings.EnableInboxis nowbool?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:AddBrokerMessagingstill returns early forMessageBusProvider.InProcess(.../MMCA.Common.Infrastructure/DependencyInjection.cs:755), so this scopes to broker hosts, and past itsettings.IsInboxEnabled(:798) selects the scopedEfInboxStore(:800) or the singletonNoOpInboxStoreplus the startup-warning hosted service (:804,:809). ReachingInboxDisabledWarningServicetherefore 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 theInboxMessagestable 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 forgetsEnableInboxgets no dedup" no longer describes a reachable state: forgetting the flag now yields dedup, and only writingfalseremoves it.- The inbox row is staged into the handler's own unit of work. The consume path is
TryBeginAsync-> handlers ->CompleteAsync, withAbandonon the failure branch (.../Persistence/Inbox/IInboxStore.cs:9-14); all three are default interface members over the originalAlreadyProcessedAsync/MarkProcessedAsyncpair (:38-39,:48-49,:63), soNoOpInboxStoreand any hand-written implementation keep working unchanged.EfInboxStore.TryBeginAsyncre-checks and then stages anInboxMessageinto the same scopedApplicationDbContextthe 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 ownSaveChangesAsynctherefore 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.CompleteAsyncthen writes only if nothing else did, keying on the entry still beingAdded(:71-84), and falls back to the old write-after-handlers path for a caller that skippedTryBeginAsync(: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 finalCompleteAsync(: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).Abandondetaches a still-Addedentry rather than leaving it staged, because the context is cached for the whole scope and a survivingAddedrow 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, andAbandonreturnsfalseafter 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
SaveStagedAsyncdetaches 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, theDbUpdateExceptionsurfaces 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:DatabaseNamepair, 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 byCompleteAsync, so delivery there stays at-least-once, which is the contract handlers are written against anyway (:23-29).
- The failure path discards the staged row first. A throwing handler triggers
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
SaveChangesAsynccommits the staged inbox row with its mutations. The framework's persisting handlers are singletons deriving fromScopedIntegrationEventHandlerBase, 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;CompleteAsyncwrites 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-37rather than inProgram.cs, so the Decision cites that file. - Current locations for facts recorded in older Revisions.
EnsureOutboxAvailableForProvideris atDependencyInjection.Messaging.cs:202-209(called at:60); the in-process early return at:52-55,EfInboxStoreat:110,NoOpInboxStoreat:118,PreserveDefaultEndpointNamesread at:79andApplicationNamespace.Resolveat:82.MessageBusSettings.EnableInboxis at.../Messaging/MessageBusSettings.cs:133,IsInboxEnabledat:141,EndpointPrefixat:73. InIntegrationEventConsumer.csthe origin restore is at:64-66, the inbox name at:71,TryBeginAsyncand the duplicate skip at:82-86,Abandonat:102, the rethrow at:109. In.../Persistence/Inbox/EfInboxStore.cs,TryBeginAsyncis at:62,CompleteAsyncat:72,Abandonat:93andSaveStagedAsyncat:130(itsDbUpdateExceptioncatch at:141).ApplicationDbContextcallsConfigureInboxat.../Persistence/DbContexts/ApplicationDbContext.cs:426and defines it at:721(IX_InboxMessages_MessageIdat:729,IX_InboxMessages_ProcessedOnat:734).ConsumerOriginRestore.Applyis at.../Messaging/Consumers/ConsumerOriginRestore.cs:35and callsAmbientOrigin.Restore(.../Context/AmbientOrigin.cs:156) at:50. TheDependencyInjection.csanchors in the Revisions (2026-08-18) and (2026-09-07) are historical: that registration now lives inDependencyInjection.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.
- Anchors re-verified against current source: ADC Engagement registers its four consumers
(
AttendeeCheckedIn,SessionFeedbackSubmitted,EventFeedbackSubmitted,UserDeleted, same set and order) atMMCA.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-55and:118;.../Messaging/MessageBusSettings.cs:141;IntegrationEventConsumer.cs:77-81and:123;ScopedIntegrationEventHandlerBase.cs:14-16and:51; the ADC Identity, ADC Conference, Store Sales and Store Catalog consumer registrations; and the sevenEnableInboxappsettings lines.