Architecture Decision Record
ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)
Status
Accepted (2026-08-13). The implementation lands in the MMCA.Common enterprise capability wave release,
alongside the scheduler, audit trail, DSAR export, and CSV export work. It is opt-in:
AddMultiTenancy(configuration) binds the settings section, and with no host calling it the mechanism is
inert (Tenancy:Enabled false, no tenant resolved), so the framework release is non-breaking.
Revised 2026-08-14: re-anchored the ApplicationDbContext, middleware, repository, and outbox citations to
current source, and corrected the repository's named-filter exclusion (five call sites through one shared
field, not four inline arrays).
Revised 2026-08-18: that exclusion now runs at six call sites in EFReadRepository, and the field and
every call site were re-anchored again. The count has moved four to five to six across three revisions,
which is the point: the shared field is the invariant, the number of read paths using it is not.
Revised 2026-08-23: re-anchored the middleware citations after the edge pipeline moved into a named-step
registry (MiddlewarePipelineBuilder plus MiddlewarePipelineStepNames, the order frozen by the
MiddlewarePipelineOrderTestsBase fitness function rather than by inline calls); corrected the write-side
guard, which is not a no-op when no tenant is resolved (an insert of an untenanted ITenantEntity from
an untenanted scope throws); restated why DesignTimeDbContextHelper registers that interceptor, since
OnConfiguring now resolves it with GetService and its absence no longer breaks dotnet ef; and
replaced the claimed adoption-sweep grep with what is verifiable, namely zero parameterless
IgnoreQueryFilters() call sites in MMCA.Common/Source today and no automated gate against a future one.
Revised 2026-08-31: the named-filter exclusion now runs at eight call sites in EFReadRepository
(four to five to six to eight across four revisions, the shared field still the invariant), and the
ApplicationDbContext filter and interceptor anchors, the interceptor registrations in
DependencyInjection and DesignTimeDbContextHelper, and the outbox per-tenant scope and claim-lease
anchors were all re-anchored to current source.
Revised 2026-09-11: the tenant now crosses the broker. This record never listed the gap, but a
consumer service could not resolve the tenant of an integration event: the outbox row and the broker
message both arrived untenanted, so the delivery ran in the system context. The row now stores the
tenant it was written under and the consumer restores MMCA-Tenant-Id before anything reads the
scope; see the Revision (2026-09-11) at the end.
Revised 2026-10-01 (the Decision no longer claims the outbox row has no tenant column, and the
tenant-unaware factory is now PhysicalDbContextFactory; see Revision below).
Context
MMCA.Common already partitions data along two axes and neither of them is a tenant. ADR-006 partitions by
source name (every entity resolves to a DataSourceKey(Engine, Name), each module or service owning
its own database) and ADR-018 partitions by engine. A third axis, "which customer does this row belong
to", had no recorded answer, which meant every consumer that ever needed one would invent it: a TenantId
column here, a Where clause in each handler there, and one forgotten handler is a customer data leak.
The framework does have the machinery this needs, built for a different reason. ApplySoftDeleteFilters
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:454,
called from OnModelCreating (:417) at :419) proves that a global predicate applied by expression tree
to every matching entity type makes an invariant unforgettable, and EF10's named query filters
(modelBuilder.Entity(clrType).HasQueryFilter(SoftDeleteFilterName, filter), :466, the name itself a
constant at :475) mean a second filter can be added beside the first rather than replacing it. The
interceptor pipeline resolved in OnConfiguring (:298-323) proves that a write-side rule can be enforced
once for every context.
That left a specific set of open questions: whether a tenant is a row discriminator or a database, what happens when no tenant is resolved, where the tenant comes from on an inbound request, whether the outbox and the migration runner (which have no request and therefore no claims) can reach tenant data at all, and whether one cached EF model serves many tenants or each tenant compiles its own.
Decision
Ship shared-schema tenancy as a second named query filter, with per-tenant database routing as a configuration override on the same source key, both opt-in and both inert until a host asks for them.
The tenant filter is a second named filter, composed with soft-delete
ApplicationDbContext gains ApplyTenantFilters, applied beside ApplySoftDeleteFilters in
OnModelCreating. Named filters compose with AND automatically, so an entity that is both
IAuditableEntity and ITenantEntity carries both predicates and neither knows about the other. The
tenant predicate is e => CurrentTenantId == null || e.TenantId == CurrentTenantId, built as an
expression tree that embeds Expression.Constant(this): EF rewrites a context-typed constant inside a
filter to the executing context instance at query-compile time, so one cached model per source serves
every tenant and the tenant value arrives as a SQL parameter rather than as a literal baked into a
per-tenant model. A dedicated two-tenants-one-cached-model Sqlite test locks that in, because it is a
property of EF's filter rewriting rather than of code this repo owns.
A null tenant is the system context and sees everything: background services, the migration runner, and
admin flows run without a resolved tenant by construction. CosmosDbContext.OnModelCreating calls
ApplyTenantFilters too, because it builds its filters independently rather than inheriting them.
ITenantEntity carries a plain string
ITenantEntity (Domain, Source/Core/MMCA.Common.Domain/Interfaces/ITenantEntity.cs) declares
string TenantId { get; }: max 64 characters, no public setter. The interceptor stamps it through
entry.Property(...).CurrentValue, so an entity cannot set its own tenant and a handler cannot move a row
between tenants by assignment. The type is string deliberately, and this is where the record departs
from ADR-048: a tenant identifier arrives from a JWT claim, an HTTP header, or a configuration key, all
three of which are text, so a TenantIdentifierType alias would add a conversion at every one of those
boundaries and buy nothing. A tenant is never a key this system generates the way an entity identifier is.
ITenantContext mirrors ICorrelationContext, one scope means one tenant
ITenantContext (Application) exposes TenantId, IsResolved, and SetTenant, mirroring
ICorrelationContext in shape and lifetime. The scoped TenantContext implementation makes SetTenant
idempotent for the same value and throws on a different value: a scope that has already answered
queries for tenant A must not start answering them for tenant B, so the invariant is enforced, not advised.
Resolution is claim, then header, and fails closed
TenancySettings binds section Tenancy through the ADR-070 chain: Enabled (false), ResolutionOrder
([Claim, Header]), ClaimType (tenant_id), HeaderName (X-Tenant-Id), RequireTenant (true),
ExcludedPathPrefixes (health, alive, .well-known), and a Tenants:{id}:DataSources:{sourceName} map of
per-tenant connection strings. Host-based resolution (tenant from subdomain) is deferred: it needs a
hostname-to-tenant map and certificate handling that no consumer needs today.
TenantResolutionMiddleware (Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs)
mirrors CorrelationIdMiddleware, which is a named step of the same edge pipeline
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:40, its name a
constant at MiddlewarePipelineStepNames.cs:20). Tenant resolution is its own step
(MiddlewarePipelineBuilder.cs:107-113), placed immediately after the Authentication step
(:103-105), because a claim-first resolution order requires that HttpContext.User already be populated.
UseCommonMiddlewarePipeline
(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:48, with a
configure overload at :60) applies the steps through ApplyPipeline (:168), which seeds
MiddlewarePipelineBuilder.CreateDefault() (MiddlewarePipelineBuilder.cs:31): the order is data,
frozen by the MiddlewarePipelineOrderTestsBase fitness function rather than by a sequence of inline
Use* calls. Like the SoftDeletedUserFilter step (MiddlewarePipelineBuilder.cs:123-125, its name at
MiddlewarePipelineStepNames.cs:59) the tenant step is registered unconditionally and inert by default,
keeping the pipeline one shape across every host. When RequireTenant is true and nothing resolves on a
non-excluded path, it returns 400 with a ProblemDetails body.
Writes are guarded by their own interceptor
TenantSaveChangesInterceptor is a separate interceptor from the audit one (one concern per
interceptor, matching how audit stamping and domain-event capture are already split at :298-299, where
it is registered between the two at :308). It stamps TenantId on Added entries and throws
CrossTenantWriteException on any Added, Modified, or Deleted entry whose tenant differs from the
resolved one. It is always registered
(TryAddSingleton<TenantSaveChangesInterceptor>,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:69), and with no tenant
resolved it is inert for updates, deletes, and every entity that does not carry ITenantEntity: the
system context is unrestricted on the write side exactly as it is on the read side. Inserts are the one
exception. StampOrVerifyInsert throws CrossTenantWriteException.ForUnresolvedTenant when an Added
ITenantEntity declares no tenant of its own and the scope resolved none
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:107-110),
because a row that no tenant can ever read is a worse outcome than a failed save; a system scope that
names the tenant explicitly (a seeder, a per-tenant job) still writes.
DesignTimeDbContextHelper registers it too
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:155),
so dotnet ef scaffolds against exactly the runtime interceptor pipeline for consumers with and without
tenancy. Its absence is survivable rather than fatal: OnConfiguring resolves the tenant interceptor with
GetService (ApplicationDbContext.cs:306) and falls back to the two-interceptor chain (:310-313), so a
directly-constructed test or design-time context that never registered it still builds.
The tenant is read live, not copied at context creation
ApplicationDbContext gains internal Func<string?>? TenantIdAccessor and
CurrentTenantId => TenantIdAccessor?.Invoke() (ApplicationDbContext.cs:140, :147). The scoped
context factory assigns () => tenantContext.TenantId when it creates a context
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:151), and the value is read at query-compile time
rather than captured at construction. Copying at creation would make correctness depend on the middleware
having run before the first context in the scope existed, a hazard no test catches and a reordered
pipeline reintroduces silently.
DB-per-tenant is a connection-string override behind the same key
When TenancySettings.Tenants[tenant].DataSources[key.Name] has an entry, the scoped DbContextFactory
clones the resolver's PhysicalDataSource
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/PhysicalDataSource.cs:20)
with the override connection string and the same DataSourceKey (DbContextFactory.cs:185-192), so EF's model cache key is unchanged
and one model still serves every tenant. Creation goes through a new
IPhysicalDbContextFactory.Create(key, physical) overload (:37) beside the original single member
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/IPhysicalDbContextFactory.cs:22).
That overload is deliberately not a default interface member: it is additive-breaking for any consumer
with a custom implementation, and hiding that behind a default body would turn a compile error into a
runtime routing surprise, so it goes in the CHANGELOG as breaking-for-implementors. The tenant is not part
of the per-scope context cache key; instead a guard throws if the scope's tenant changes after an
overridden context exists, restating the one-scope-one-tenant invariant where it would otherwise break.
ignoreQueryFilters stops meaning "ignore everything"
EFReadRepository names the one filter it means to drop instead of dropping every filter. A single shared
field carries the name,
private static readonly string[] SoftDeleteFilterOnly = [DbContexts.ApplicationDbContext.SoftDeleteFilterName]
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:40), and
it is passed to IgnoreQueryFilters at all eight call sites (:57, :86, :107, :203, :393,
:470, :484, :571): one field rather than eight inline arrays, so the set of filters a
soft-delete-inclusive read drops cannot diverge between two of them. The repository's ignoreQueryFilters: true parameter has always meant
"include soft-deleted rows", and naming the filter keeps that meaning exactly while making it impossible
for a soft-delete-inclusive read to cross tenants.
Background work drains and migrates per tenant
OutboxProcessor and OutboxCleanupService enumerate (source, tenant?) pairs from TenancySettings
(TenantDataSourceTargets.ExpandRelational, called at OutboxProcessor.cs:148: one shared target per
source plus one target per tenant that overrides that source) and call ITenantContext.SetTenant inside
the per-target scope before obtaining the context, through the shared CreateTenantScope helper
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:134-145,
called at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:192
and MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Administration/OutboxCleanupService.cs:103), so the
factory routes to the tenant's database and the claim-lease update (OutboxProcessor.cs:401-405, the ExecuteUpdateAsync
that sets LockedUntil and LockToken) runs against the right rows. Routing does not depend on a tenant
column, but the row does carry one: OutboxMessage.TenantId
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:93) is a nullable
string mapped with a 64-character, non-Unicode limit
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:684),
and it records the tenant the row was written under so delivery can restore it (see the Revision
(2026-09-11)).
InitializeDatabaseAsync gains a per-tenant pass: a fresh scope per tenant, SetTenant, then the same
DatabaseInitStrategy semantics and migrations assembly per overridden source. Module seeding stays
default-scope-only in v1, documented as such rather than half-implemented.
Cache isolation lives in the decorators
ICacheService is a singleton and cannot observe scoped state, so tenant awareness goes one layer up:
CachingQueryDecorator and CachingCommandDecorator take ITenantContext and prefix the cache key, the
invalidation prefix, and the stripe lock with t:{tenantId}: when a tenant is resolved. Doing it in both
decorators is what makes read isolation and invalidation isolation symmetric; doing it in one would let
tenant A's write evict tenant B's entry or fail to evict its own. Direct ICacheService consumers (login
counters, OAuth state, idempotency records) are keyed by subject already and are unchanged.
AddMultiTenancy(configuration) additionally validates on start that every override names a source that
exists, so a typo fails the host rather than silently routing a tenant to the default database.
Adoption: Helpdesk demonstrates it, ADC and Store do not adopt it
MMCA.ADC and MMCA.Store are single-tenant production applications, so adding a tenant filter and a
TenantId column to their aggregates is scope creep with real deploy risk (two production deployments,
migrations on every source) for a capability neither product sells. MMCA.Helpdesk is the reference demo:
its Tickets entities implement ITenantEntity, it is configured with two tenants, and one of them gets a
per-tenant database override, so the runnable seed exercises both halves of this record.
Rationale
- A query filter is the only place the rule cannot be forgotten. Per-handler
Whereclauses are correct until the tenth handler, and the tenth handler is a data leak rather than a bug report. The soft-delete filter has proven the mechanism across three applications. - Named filters are why this is additive at all. Before EF10, a second global filter would have
replaced the soft-delete one, forcing both predicates into one hand-composed expression. Naming them
keeps
IgnoreQueryFilters(["SoftDelete"])expressible, so the repository's contract survives unchanged. - Embedding the context constant is what keeps one model. Closing over the tenant string at model-build time, the obvious implementation, produces a distinct compiled model per tenant and turns the model cache into a memory leak proportional to tenant count, buying nothing a SQL parameter does not.
- Null-tenant-sees-all keeps background work simple. The alternative, a system context that enumerates tenants to see everything, pushes tenancy into the outbox loop, the cleanup service, and the migration runner as a control-flow concern rather than as a routing detail.
- Fail-closed is the only defensible default for isolation.
RequireTenantdefaults to true because the failure mode of the other default is serving one customer's data to another, silently, with a 200. A 400 is loud, immediate, and visible in the first smoke test. - Same
DataSourceKey, different connection string, is the smallest possible DB-per-tenant. An override changes where a source points, not what a source is, so the entity registry, the model cache, the migrations assembly, and the per-source outbox all keep working unchanged. - A live accessor removes an ordering hazard rather than documenting it.
- The decorator is where the cache knows about scope. Pushing tenancy into
ICacheServicewould mean a singleton reaching for scoped state, which is exactly the shape that produces cross-request bleed.
Trade-offs
- Reads are on discipline where writes are on an invariant. A consumer calling EF's own parameterless
IgnoreQueryFilters()on a rawTablesurface drops the tenant filter along with soft-delete. Writes remain guarded byTenantSaveChangesInterceptor, andMMCA.Common/Sourcecarries zero parameterlessIgnoreQueryFilters()call sites today (the only mentions are the doc comments atEFReadRepository.cs:37andTenantSaveChangesInterceptor.cs:32explaining why the named form is used), but no automated gate holds that count: no architecture rule, fitness test, or CI step checks for the parameterless form, so nothing fails a build if a future call site appears. ADR-055'sApplicationLayer_DoesNotUseRawQueryableSurfacesis partial cover only: it does not reach Infrastructure. - The singleton physical factory is tenant-unaware by design.
PhysicalDbContextFactory.Create(both overloads,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:37and:40) has no scope and therefore no tenant: only the scopedDbContextFactoryassignsTenantIdAccessor(DbContextFactory.cs:151), so a context obtained straight from the physical factory sees every tenant's rows. - Fail-closed means a misconfigured claim takes the whole surface down. A deployment whose identity
provider stops emitting
tenant_idreturns 400 on every non-excluded route rather than degrading to a reduced view. That is the intended trade, and it is still an outage. - Shared-schema turns one bad filter into a data leak rather than an error, and a missing
ITenantEntitymarker on a new entity fails no build and no test: it silently includes that entity's rows in every tenant's queries. - The system context is a privileged mode with no second gate. Any code path that runs without a resolved tenant reads across all tenants, and nothing distinguishes "deliberately system" from "middleware did not run here".
- DB-per-tenant multiplies operational surface. Each override adds a migration pass at startup and its own connection pool, so both scale with tenant count, and the per-tenant connection strings live in configuration, making the tenant roster a deployment artifact rather than data.
- Cache isolation stops at the decorator. Code resolving
ICacheServicedirectly gets no tenant prefix. Today's direct consumers are subject-scoped (login counters, OAuth state, idempotency records) so they are safe by accident of their key shape, not by a rule that would catch the next one. - Module seeding is default-scope-only in v1. A per-tenant database gets migrations but not seed data.
Related
ADR-006 (the source-name axis this composes with: an override re-points a
DataSourceKey without changing it, and the per-source outbox this record drains once per tenant),
ADR-005 (the filter this one sits beside and composes with by name, and
whose IgnoreQueryFilters contract narrows to ["SoftDelete"]),
ADR-018 (the engine axis; CosmosDbContext applies the tenant filters
independently because it builds its own), ADR-026 and
ADR-014 (the caching decorators that gain the t:{tenantId}: prefix on
key, invalidation prefix, and stripe lock), ADR-048 (why
TenantId is a plain string and not an identifier alias),
ADR-055 (the raw-queryable rule that partially covers the
read-side bypass), ADR-030 (who applies migrations, now once per tenant per
overridden source), ADR-070 (the validating settings chain
TenancySettings binds through, extended with a check that every override names a known source).
Revision (2026-09-11)
The gap this closes was never written down here. Everything above is about the tenant inside a request: the middleware resolves it, the named filter composes on it, the write interceptor stamps and refuses on it, the outbox is drained once per source and tenant pair, and the caching decorators prefix on it. What no section covered is an event leaving one service and arriving in another. The outbox row carried no tenant and the broker message carried no tenant, so the delivery ran in the system context, which this record's own Trade-offs already name as "a privileged mode with no second gate": on a shared-schema host that delivery read across tenants, and on a database-per-tenant host it had no way to pick a database at all.
The tenant is now part of both hops, through one helper. On the producer side the scoped context
factory captures ITenantContext.TenantId into the row's origin
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:149-:153),
OutboxMessage.TenantId stores it
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:93, mapped
as varchar(64) at
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:652),
and the processor restores it before the row is dispatched
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:607-:613).
Across the broker, BrokerMessageBus stamps MMCA-Tenant-Id
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMessageBus.cs:94, the constant at
MMCA.Common/Source/Core/MMCA.Common.Shared/Messaging/MessageHeaders.cs:25) and the consuming side
reads it back
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/ConsumerOriginRestore.cs:53)
before the inbox is touched
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumer.cs:62-:64),
which matters here specifically: under database-per-tenant the inbox store resolves its context
through the same scope, so restoring the tenant afterwards would put the dedup row in the wrong
database (ADR-021).
The restore respects the one-scope-one-tenant rule this record set. AmbientOrigin.Restore sets
the tenant only when the scope has not already resolved a different one
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Context/AmbientOrigin.cs:137-:143), and it is
restored first, before any repository or context is resolved on that scope, because the tenant routes
the context factory and every query filter afterwards reads from it (:135-:136). A host running
database per tenant is unaffected by the guard, since its work is already one scope per tenant.
What is still on discipline. A message published by a service that has not upgraded carries no tenant header, and a row written before the migration reads back as null, so those deliveries run untenanted in the system context, exactly as every delivery did before. Nothing fails a build or a startup to say that one publisher in the mesh is still behind: the signal is an absent tenant, which looks the same as a legitimately system-raised event. Adoption is also unchanged, and it is why this closes a hole rather than fixing an outage: ADC and Store are single-tenant, and Helpdesk remains the reference adopter.
Revision (2026-10-01)
The body now agrees with the column the 2026-09-11 Revision added. "Background work drains and
migrates per tenant" still stated that there was deliberately no OutboxMessage schema change, and the
Rationale still credited the absent column with keeping the upgrade free. Both were wrong after
2026-09-11: OutboxMessage.TenantId exists
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:93), nullable and
mapped at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:684.
The Decision now says so and the Rationale clause is dropped. Database routing for background work is
unchanged in substance: the targets come from TenantDataSourceTargets.ExpandRelational and the tenant is
set through the shared CreateTenantScope helper
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:134-145).
That file's remarks (TenantDataSourceTargets.cs:32-33) still say the outbox has no tenant column; the
source comment is stale, not the column.
The tenant-unaware factory named in Trade-offs no longer exists. DefaultSqlServerDbContextFactory and
its siblings are gone from MMCA.Common/Source; the remaining runtime path with no tenant is the singleton
PhysicalDbContextFactory.Create (both overloads,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/PhysicalDbContextFactory.cs:37
and :40), and at design time DesignTimeDbContextHelper constructs contexts directly without one
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:56,
:78, :99), since only the scoped factory assigns TenantIdAccessor
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:151,
without a null-conditional). The trade itself is unchanged.
Anchors refreshed. The ApplicationDbContext filter, OnConfiguring and interceptor anchors, the
middleware step and ApplyPipeline anchors, the interceptor registrations in DependencyInjection and
DesignTimeDbContextHelper, PhysicalDataSource, the eight EFReadRepository call sites (still eight),
and the outbox scope and claim-lease anchors were re-anchored to current source. The anchors inside the
Revision (2026-09-11) are left as that record wrote them.