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

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 Where clauses 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. RequireTenant defaults 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 ICacheService would 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 raw Table surface drops the tenant filter along with soft-delete. Writes remain guarded by TenantSaveChangesInterceptor, and MMCA.Common/Source carries zero parameterless IgnoreQueryFilters() call sites today (the only mentions are the doc comments at EFReadRepository.cs:37 and TenantSaveChangesInterceptor.cs:32 explaining 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's ApplicationLayer_DoesNotUseRawQueryableSurfaces is 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:37 and :40) has no scope and therefore no tenant: only the scoped DbContextFactory assigns TenantIdAccessor (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_id returns 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 ITenantEntity marker 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 ICacheService directly 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.

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.