Summary
Chatter.MessageBrokers.Reliability.EntityFramework normalizes, replaces, and publishes guarantees about service descriptors it holds no provenance handle on. When the package needs to find "its own" reliability behaviour registrations in the IServiceCollection, it cannot ask which descriptors it created, because it never recorded that. Instead it re-identifies them after the fact by testing ambient attributes of each descriptor: the service type, the implementation type, whether the descriptor is keyed. Descriptor identity is inferred, not derived from ownership.
The same gap shows up on the documentation side. Because the guarantee the package publishes is not derivable from any single piece of data the package holds, it is hand-replicated as prose across seven artifacts, and each restatement then drifts independently.
Both halves have the same cause, and both are visible in the commit history of fix/379-inbox-behavior-order.
Root-cause scope
The class is: descriptor identity is inferred from ambient attributes rather than derived from ownership, and the resulting guarantee is hand-replicated rather than derived from one source.
#481 states the test for tracking a same-surface finding separately: "This is a distinct root class from #480. #480 is about which participant owns a commit point; this one is about which context the participants are pointed at. Fixing either does not fix the other, which is why it is tracked separately rather than as a comment on #480."
The same test applies here, and it passes. #481 governs which TContext the participants point at. This issue governs how descriptor identity is established in the first place. Fixing #481 — binding the three participants to one context by construction — leaves IsBehaviorDescriptorFor still guessing which descriptors belong to the package from ambient attributes, and leaves the published guarantee still hand-copied across seven files. Fixing provenance — giving the package a real handle on what it registered — leaves the contexts still unbound, so a mixed-TContext registration still commits the wrong DbContext. Neither subsumes the other.
Evidence: one attribute per adversarial review pass
The identifying predicate is IsBehaviorDescriptorFor in src/Chatter.MessageBrokers.Reliability.EntityFramework/src/Chatter.MessageBrokers.Reliability.EntityFramework/Extensions.cs:127. It is the sole identity test behind both AddReliabilityBehaviorOnce (line 75) and IsReliabilityBehaviorDescriptor (line 116), so everything the package claims about "its" descriptors rests on it. Its current form is:
private static bool IsBehaviorDescriptorFor(ServiceDescriptor descriptor, Type openGenericBehaviorType)
=> !descriptor.IsKeyedService
&& descriptor.ServiceType.IsGenericType
&& descriptor.ServiceType.GetGenericTypeDefinition() == typeof(ICommandBehavior<>)
&& descriptor.ImplementationType == openGenericBehaviorType;
Every conjunct is an ambient attribute of a descriptor. None of them says "this package registered this". The predicate has grown one attribute per adversarial review pass on this branch:
- Pass 1 — service type, matched as an exact open generic. The predicate shipped in
4f20178 ("guarantee a canonical reliability behaviour order") with the ICommandBehavior<> open-generic test, which is what keeps normalization away from the IUnitOfWork to UnitOfWork<TContext> descriptor and leaves an application's own closed-generic registration out of the reliability set.
- Pass 2 —
ImplementationType. Also present from 4f20178, and it is what the pass-2 remediation a21b587 leaned on when the docs first enumerated the registration shapes normalization cannot own: "closed-generic, factory, keyed, or decorated registrations of these behavior types, are intentionally outside normalization and are not reordered". That carve-out list is the shape of the problem — four registration forms the predicate cannot identify, documented as out of scope precisely because identity is inferred.
- Pass 3 —
IsKeyedService. Landed in 210f04f ("keep keyed registrations out of the behaviour scan"). Note that "keyed" was already named in the pass-2 documentation carve-out, but was not actually enforced in the predicate until a pass later — and enforcing it also required a new ordering INVARIANT comment, because ServiceDescriptor.ImplementationType throws on a keyed descriptor in Microsoft.Extensions.DependencyInjection.Abstractions 8.0.0 and returns null from 8.0.2 onward.
Three consecutive adversarial passes on the same surface, each adding or hardening one attribute, is the signature of an inferred identity rather than a defect in any one conjunct. There is no reason to believe the fourth pass would not add a fourth attribute.
The prose half: the same guarantee restated in seven artifacts
Because the guarantee is not derivable from one piece of data, it is written out by hand in seven artifacts, all of which must be kept in step manually:
src/Chatter.MessageBrokers.Reliability.EntityFramework/src/README.md (Reliability Behavior Order, plus the Inbox section)
src/Chatter.MessageBrokers.Reliability.EntityFramework/src/Chatter.MessageBrokers.Reliability.EntityFramework/CHANGELOG.md
src/Chatter.MessageBrokers.Reliability.EntityFramework/docs/characterization-findings.md (finding 3)
src/Chatter.MessageBrokers.Reliability.EntityFramework/CONTEXT.md
docs/adr/0006-two-tier-reliability-relational-ambient-tx-vs-nosql-stage-then-commit.md
src/Chatter.MessageBrokers.Reliability.EntityFramework/src/Chatter.MessageBrokers.Reliability.EntityFramework/BrokeredMessageInbox.cs (both the INVARIANT comment and the XML doc)
src/Chatter.MessageBrokers.Reliability.EntityFramework/src/Chatter.MessageBrokers.Reliability.EntityFramework/Extensions.cs (the INVARIANT comments)
An eighth restatement sits in the test comment block above MustNotDeclareADbContextField in src/Chatter.MessageBrokers.Reliability.EntityFramework/tests/UsingBrokeredMessageInbox/WhenReceivingViaInbox.cs.
The prose has now drifted three independent times on this branch. The most recent drift was a false precondition — that the commit-together guarantee "holds only when all three extension methods are called with the same TContext" — which is wrong, because a lone WithInboxBehavior<TContext>() call registers the matching unit of work itself and is therefore safe. That false claim was introduced into three separate artifacts at once by eb2a54a, and then had to be corrected in two commits touching different files: 210f04f corrected the README and the characterization findings, and b1c0ce1 corrected the CHANGELOG.
The correction is still incomplete. BrokeredMessageInbox.cs:53 retains the superseded wording:
/// The guarantee holds when all three extension methods are called with the same
/// <typeparamref name="TContext"/>.
That is exactly the failure mode hand-replication produces: an N-way copy corrected at three of its four then-current sites, with the fourth left stating the superseded rule. This residual site is called out here rather than patched, because patching one more copy is the behaviour this issue is about.
Bounded impact
The predicate can only misclassify registrations of the three reliability behaviour types — InboxBehavior<>, UnitOfWorkBehavior<>, OutboxProcessingBehavior<> — that the package did not itself create. A consumer on a single DbContext, with no keyed registrations of these behaviour types and no decoration of them, cannot reach the hazard: every descriptor the predicate matches is in fact one the package registered, so inference and ownership agree.
The documentation-drift half has wider reach, since incorrect published preconditions can mislead any reader, but it produces no runtime failure on its own.
What was fixed now, and what is deferred here
Fixed now on fix/379-inbox-behavior-order:
- The
IsKeyedService guard was added ahead of the ImplementationType read, so keyed registrations are excluded from the behaviour scan and the documented "keyed registrations are not reordered" claim now holds across the whole supported 8.0.x range rather than only on patched hosts (210f04f).
- The false same-
TContext precondition was corrected at three sites — the package README, the characterization findings, and the package CHANGELOG (210f04f, b1c0ce1).
Deferred to this issue:
- The provenance-handle rewrite: give the package a real handle on the descriptors it registers, so identity derives from ownership instead of being inferred from ambient attributes.
- Single-source derivation of the published guarantee, so the ordering-and-durability contract is stated once and referenced everywhere else, instead of being hand-copied into seven artifacts.
- The residual stale restatement at
BrokeredMessageInbox.cs:53, which should be swept by whatever single-source mechanism lands here rather than corrected as a ninth hand-edit.
Proposed direction
Give the package a provenance handle on what it registers. Concretely, when a reliability extension method registers a behaviour, record that registration — for example in a package-owned marker or registry carried on the IServiceCollection — and have AddReliabilityBehaviorOnce and NormalizeReliabilityBehaviorOrder consult that record instead of re-deriving identity from ServiceType, ImplementationType, and IsKeyedService. Descriptors the package did not create then fall outside the set by construction, not by an accumulating list of attribute tests, and a fourth registration shape does not require a fourth conjunct.
Then derive the published guarantee from one source. State the ordering rule and its durability precondition once — the ADR is the natural home — and have the README, CHANGELOG, CONTEXT, characterization findings, and source comments reference it rather than restate it. A single restatement cannot drift out of step with itself.
Any fix should come with a test that a registration shape the package did not create is excluded because it was not recorded, rather than because it failed an attribute test.
Minor unreported nit: the reflection guard's binding flags
Recorded here so it is not lost. At src/Chatter.MessageBrokers.Reliability.EntityFramework/tests/UsingBrokeredMessageInbox/WhenReceivingViaInbox.cs:175, the MustNotDeclareADbContextField guard filters:
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
Those flags were verified on the current branch. A public field, or a static field, of a DbContext-assignable type would not be returned by that call, so it would slip past a guard whose surrounding prose describes the type as declaring no DbContext-assignable field. The gap is narrow — a public or static DbContext field on BrokeredMessageInbox<TContext> would be an odd thing to add — but the guard is a regression tripwire, and a tripwire that does not cover the cases its own description claims is worth widening to BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static.
Provenance
Deferred with scope from the #379 review cycle. This is the third root identified by three consecutive same-surface local adversarial review passes on branch fix/379-inbox-behavior-order, alongside the persistence-port deferral in #480 and the mixed-TContext deferral in #481. See #379, #480, #481, and commits 4f20178, a21b587, eb2a54a, 210f04f, and b1c0ce1 on that branch.
Summary
Chatter.MessageBrokers.Reliability.EntityFrameworknormalizes, replaces, and publishes guarantees about service descriptors it holds no provenance handle on. When the package needs to find "its own" reliability behaviour registrations in theIServiceCollection, it cannot ask which descriptors it created, because it never recorded that. Instead it re-identifies them after the fact by testing ambient attributes of each descriptor: the service type, the implementation type, whether the descriptor is keyed. Descriptor identity is inferred, not derived from ownership.The same gap shows up on the documentation side. Because the guarantee the package publishes is not derivable from any single piece of data the package holds, it is hand-replicated as prose across seven artifacts, and each restatement then drifts independently.
Both halves have the same cause, and both are visible in the commit history of
fix/379-inbox-behavior-order.Root-cause scope
The class is: descriptor identity is inferred from ambient attributes rather than derived from ownership, and the resulting guarantee is hand-replicated rather than derived from one source.
#481 states the test for tracking a same-surface finding separately: "This is a distinct root class from #480. #480 is about which participant owns a commit point; this one is about which context the participants are pointed at. Fixing either does not fix the other, which is why it is tracked separately rather than as a comment on #480."
The same test applies here, and it passes. #481 governs which
TContextthe participants point at. This issue governs how descriptor identity is established in the first place. Fixing #481 — binding the three participants to one context by construction — leavesIsBehaviorDescriptorForstill guessing which descriptors belong to the package from ambient attributes, and leaves the published guarantee still hand-copied across seven files. Fixing provenance — giving the package a real handle on what it registered — leaves the contexts still unbound, so a mixed-TContextregistration still commits the wrongDbContext. Neither subsumes the other.Evidence: one attribute per adversarial review pass
The identifying predicate is
IsBehaviorDescriptorForinsrc/Chatter.MessageBrokers.Reliability.EntityFramework/src/Chatter.MessageBrokers.Reliability.EntityFramework/Extensions.cs:127. It is the sole identity test behind bothAddReliabilityBehaviorOnce(line 75) andIsReliabilityBehaviorDescriptor(line 116), so everything the package claims about "its" descriptors rests on it. Its current form is:Every conjunct is an ambient attribute of a descriptor. None of them says "this package registered this". The predicate has grown one attribute per adversarial review pass on this branch:
4f20178("guarantee a canonical reliability behaviour order") with theICommandBehavior<>open-generic test, which is what keeps normalization away from theIUnitOfWorktoUnitOfWork<TContext>descriptor and leaves an application's own closed-generic registration out of the reliability set.ImplementationType. Also present from4f20178, and it is what the pass-2 remediationa21b587leaned on when the docs first enumerated the registration shapes normalization cannot own: "closed-generic, factory, keyed, or decorated registrations of these behavior types, are intentionally outside normalization and are not reordered". That carve-out list is the shape of the problem — four registration forms the predicate cannot identify, documented as out of scope precisely because identity is inferred.IsKeyedService. Landed in210f04f("keep keyed registrations out of the behaviour scan"). Note that "keyed" was already named in the pass-2 documentation carve-out, but was not actually enforced in the predicate until a pass later — and enforcing it also required a new orderingINVARIANTcomment, becauseServiceDescriptor.ImplementationTypethrows on a keyed descriptor inMicrosoft.Extensions.DependencyInjection.Abstractions8.0.0 and returnsnullfrom 8.0.2 onward.Three consecutive adversarial passes on the same surface, each adding or hardening one attribute, is the signature of an inferred identity rather than a defect in any one conjunct. There is no reason to believe the fourth pass would not add a fourth attribute.
The prose half: the same guarantee restated in seven artifacts
Because the guarantee is not derivable from one piece of data, it is written out by hand in seven artifacts, all of which must be kept in step manually:
src/Chatter.MessageBrokers.Reliability.EntityFramework/src/README.md(Reliability Behavior Order, plus the Inbox section)src/Chatter.MessageBrokers.Reliability.EntityFramework/src/Chatter.MessageBrokers.Reliability.EntityFramework/CHANGELOG.mdsrc/Chatter.MessageBrokers.Reliability.EntityFramework/docs/characterization-findings.md(finding 3)src/Chatter.MessageBrokers.Reliability.EntityFramework/CONTEXT.mddocs/adr/0006-two-tier-reliability-relational-ambient-tx-vs-nosql-stage-then-commit.mdsrc/Chatter.MessageBrokers.Reliability.EntityFramework/src/Chatter.MessageBrokers.Reliability.EntityFramework/BrokeredMessageInbox.cs(both theINVARIANTcomment and the XML doc)src/Chatter.MessageBrokers.Reliability.EntityFramework/src/Chatter.MessageBrokers.Reliability.EntityFramework/Extensions.cs(theINVARIANTcomments)An eighth restatement sits in the test comment block above
MustNotDeclareADbContextFieldinsrc/Chatter.MessageBrokers.Reliability.EntityFramework/tests/UsingBrokeredMessageInbox/WhenReceivingViaInbox.cs.The prose has now drifted three independent times on this branch. The most recent drift was a false precondition — that the commit-together guarantee "holds only when all three extension methods are called with the same
TContext" — which is wrong, because a loneWithInboxBehavior<TContext>()call registers the matching unit of work itself and is therefore safe. That false claim was introduced into three separate artifacts at once byeb2a54a, and then had to be corrected in two commits touching different files:210f04fcorrected the README and the characterization findings, andb1c0ce1corrected the CHANGELOG.The correction is still incomplete.
BrokeredMessageInbox.cs:53retains the superseded wording:That is exactly the failure mode hand-replication produces: an N-way copy corrected at three of its four then-current sites, with the fourth left stating the superseded rule. This residual site is called out here rather than patched, because patching one more copy is the behaviour this issue is about.
Bounded impact
The predicate can only misclassify registrations of the three reliability behaviour types —
InboxBehavior<>,UnitOfWorkBehavior<>,OutboxProcessingBehavior<>— that the package did not itself create. A consumer on a singleDbContext, with no keyed registrations of these behaviour types and no decoration of them, cannot reach the hazard: every descriptor the predicate matches is in fact one the package registered, so inference and ownership agree.The documentation-drift half has wider reach, since incorrect published preconditions can mislead any reader, but it produces no runtime failure on its own.
What was fixed now, and what is deferred here
Fixed now on
fix/379-inbox-behavior-order:IsKeyedServiceguard was added ahead of theImplementationTyperead, so keyed registrations are excluded from the behaviour scan and the documented "keyed registrations are not reordered" claim now holds across the whole supported 8.0.x range rather than only on patched hosts (210f04f).TContextprecondition was corrected at three sites — the package README, the characterization findings, and the package CHANGELOG (210f04f,b1c0ce1).Deferred to this issue:
BrokeredMessageInbox.cs:53, which should be swept by whatever single-source mechanism lands here rather than corrected as a ninth hand-edit.Proposed direction
Give the package a provenance handle on what it registers. Concretely, when a reliability extension method registers a behaviour, record that registration — for example in a package-owned marker or registry carried on the
IServiceCollection— and haveAddReliabilityBehaviorOnceandNormalizeReliabilityBehaviorOrderconsult that record instead of re-deriving identity fromServiceType,ImplementationType, andIsKeyedService. Descriptors the package did not create then fall outside the set by construction, not by an accumulating list of attribute tests, and a fourth registration shape does not require a fourth conjunct.Then derive the published guarantee from one source. State the ordering rule and its durability precondition once — the ADR is the natural home — and have the README, CHANGELOG, CONTEXT, characterization findings, and source comments reference it rather than restate it. A single restatement cannot drift out of step with itself.
Any fix should come with a test that a registration shape the package did not create is excluded because it was not recorded, rather than because it failed an attribute test.
Minor unreported nit: the reflection guard's binding flags
Recorded here so it is not lost. At
src/Chatter.MessageBrokers.Reliability.EntityFramework/tests/UsingBrokeredMessageInbox/WhenReceivingViaInbox.cs:175, theMustNotDeclareADbContextFieldguard filters:Those flags were verified on the current branch. A
publicfield, or astaticfield, of aDbContext-assignable type would not be returned by that call, so it would slip past a guard whose surrounding prose describes the type as declaring noDbContext-assignable field. The gap is narrow — apublicorstaticDbContextfield onBrokeredMessageInbox<TContext>would be an odd thing to add — but the guard is a regression tripwire, and a tripwire that does not cover the cases its own description claims is worth widening toBindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static.Provenance
Deferred with scope from the #379 review cycle. This is the third root identified by three consecutive same-surface local adversarial review passes on branch
fix/379-inbox-behavior-order, alongside the persistence-port deferral in #480 and the mixed-TContextdeferral in #481. See #379, #480, #481, and commits4f20178,a21b587,eb2a54a,210f04f, andb1c0ce1on that branch.