Architecture Decision Record
ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions
Status
Accepted (2026-07-15). Revised 2026-08-07 (validator hoisted into a shared generic, the 30-second
constant moved, the two apps revoke at different speeds). Revised 2026-08-23: the pipeline
registration moved out of WebApplicationExtensions into the named-step MiddlewarePipelineBuilder
(ADR-079), and a tenant-resolution step (ADR-073) now sits between authentication and rate limiting.
Revised 2026-09-03 (MMCA.Common 1.185.0): the deleted-marker write was lifted out of the apps into
DeleteUserHandlerBase, so the revocation window is now uniform across every consumer and the
per-app asymmetry the 2026-08-07 revision recorded is gone.
Context
Soft-delete is the framework's default deletion model (ADR-005): AuditableBaseEntity.Delete() sets
IsDeleted = true and EF global query filters hide the row, but the record survives for audit,
referential integrity, and undelete. Deactivating a user account is therefore a soft-delete, not a
row removal.
Authentication is stateless JWT (ADR-004): Identity mints an access token, and every service
validates it by signature and expiry without a per-request lookup against the account store. That is
exactly what makes it scale, and it is also the problem here. A JWT is a bearer credential that stays
valid until it expires; nothing in signature validation asks "is this account still active?" So when
an administrator soft-deletes a user, that user's already-issued access token keeps passing
validation on every service until it expires on its own. The same gap applies to the SSR session
cookie that carries the JWT for fresh browser GETs (ADR-022): the cookie handler populates
HttpContext.User from a token the API already issued, so a deleted user's prerender path also stays
authenticated until the token lifetime runs out.
Closing that gap the textbook way (a token deny-list or a per-request account-status check) reintroduces exactly the stateful, per-request store lookup that stateless JWT was chosen to avoid. We wanted the deletion to take effect quickly without paying a database round trip on every authenticated request, and without coupling every extracted service to the Identity database.
Decision
Add a shared-pipeline middleware, SoftDeletedUserMiddleware
(Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31, BR-133), that
rejects an authenticated caller with HTTP 401 once the caller's account has been soft-deleted, backed
by a short cache so the account-status lookup is not paid on every request.
- It runs after authentication, before authorization. That position is data in a named-step
pipeline builder (ADR-079), not a hand-ordered sequence of
Use*calls:UseCommonMiddlewarePipeline(Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:48) delegates to the privateApplyPipelinehelper (WebApplicationExtensions.cs:140), which seeds the framework steps fromMiddlewarePipelineBuilder.CreateDefault()(WebApplicationExtensions.cs:142;Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:31-156). This middleware is theSoftDeletedUserFilterstep (MiddlewarePipelineStepNames.cs:59), applied asapp.UseMiddleware<SoftDeletedUserMiddleware>()atMiddlewarePipelineBuilder.cs:130. Four consecutive steps fix its place:UseAuthentication(:110), thenTenantResolutionMiddleware(:118, ADR-073, which has to sit immediately after authentication because its claim strategy readsHttpContext.User), thenUseRateLimiter(:126, ADR-019), then this middleware (:130), and only after itUseAuthorization(:134). SoHttpContext.Useris already populated when the check runs, and the check still gates every downstream endpoint. The order is frozen by a fitness function rather than by a comment:MiddlewarePipelineOrderTestsBase.ExpectedStepNamesassertsSoftDeletedUserFilterbetweenRateLimitingandAuthorization(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/MiddlewarePipelineOrderTestsBase.cs:51-53).MiddlewarePipelineBuilder.Build()'s startup-validated invariants (MiddlewarePipelineBuilder.cs:257-280) do not name this step, so a host that moves it through the configure overload is caught by that fitness function, not at startup. - Anonymous requests pass straight through. When
ICurrentUserService.UserIdis null the middleware calls the next delegate and returns without any lookup (SoftDeletedUserMiddleware.cs:65-73), so unauthenticated traffic pays nothing. - The account-status check is an abstraction, implemented once in the framework.
ISoftDeletedUserValidator(Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ISoftDeletedUserValidator.cs:7) exposes a singleIsUserSoftDeletedAsync(userId, ...)method (ISoftDeletedUserValidator.cs:15). One shared generic implementation,SoftDeletedUserValidator<TUser>(MMCA.Common/Source/Core/MMCA.Common.Application/Users/SoftDeletedUserValidator.cs:20-34), runs the filter-bypassing existence queryrepository.ExistsAsync(u => u.Id == userId && u.IsDeleted, ignoreQueryFilters: true, ...)(SoftDeletedUserValidator.cs:31-34) against whichever aggregate it is closed over (TUser : AuditableAggregateRootEntity<UserIdentifierType>,SoftDeletedUserValidator.cs:21). Each app closes it over its ownUserat registration and writes no subclass of its own (services.TryAddScoped<ISoftDeletedUserValidator, SoftDeletedUserValidator<User>>()atMMCA.ADC.Identity.Application/DependencyInjection.cs:35andMMCA.Store.Identity.Application/DependencyInjection.cs:44). The query bypasses the soft-delete global query filter deliberately, because a plain read would hide the very row it needs to find. - A 30-second cache amortizes the lookup. The key shape and the marker lifetime live in a shared
class rather than inside the middleware, because a module that deletes an account has to write the
exact key the middleware reads and a private constant in the presentation layer is unreachable from
an application-layer handler.
SoftDeletedUserCache.MarkerDurationisTimeSpan.FromSeconds(30)(Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:29), andSoftDeletedUserCache.KeyFor(userId)buildsuser:deleted:{userId}under the invariant culture (SoftDeletedUserCache.cs:42-43). The middleware builds that key (SoftDeletedUserMiddleware.cs:85) and reads it fromICacheServicefirst (:91): a cachedtrueshort-circuits to 401 with no database call (:102-106); a cache miss runs the validator once (:114-116), caches the boolean forMarkerDuration(:131-133), and 401s if deleted (:143-147); a cachedfalsefalls through to the next delegate (:150). So a given user costs at most one status query per 30-second window per cache scope, not one per request. - The check fails open. Every external call on the path is wrapped: a cache read failure is logged
and falls through to the validator query (
SoftDeletedUserMiddleware.cs:93-100), a validator failure is logged and the request proceeds (:118-125), and a failed cache write only costs the next request another lookup (:135-140). The class states the reasoning in its own remarks (:16-30): failing closed would turn any cache or database blip into a total outage for every authenticated request, because this middleware sits on the hot path of all of them, while failing open leaves a residual exposure bounded by the 15-minute access-token lifetime and by the refresh-token revocation the deletion already performed. - It no-ops in services that do not host Identity. The validator is resolved lazily via
context.RequestServices.GetService<ISoftDeletedUserValidator>()(SoftDeletedUserMiddleware.cs:75) rather than as anInvokeAsyncparameter. In an extracted service that does not host Identity (for example Store's Catalog or Sales service), no implementation is registered, so the middleware passes the request through (SoftDeletedUserMiddleware.cs:76-83): Identity is the source of truth and already validated the token upstream. Resolving it as a constructor/parameter dependency would instead 500 every request in those services. MMCA.Helpdesk wires the same pipeline (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:130) but hosts only a Tickets module and registers no validator, so it takes the same no-op path.
SoftDeletedUserMiddlewareTests
(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Middleware/SoftDeletedUserMiddlewareTests.cs)
covers the branches: anonymous pass-through, no-validator pass-through with no cache call, a live
non-deleted pass, a live deleted 401, a cached-deleted 401 with no database call, and a cached
non-deleted pass with no database call.
The marker write is part of the shared erasure workflow, so the window is uniform. Without a
marker the revocation is passive: a soft-deleted account's still-valid tokens keep working until the
cached status expires (at most the 30-second marker lifetime, once the account has been queried at
least once in that window) instead of until the token itself expires. Writing the marker at delete
time collapses that to the next request. That write is not left to each app's delete handler: it is a
step of DeleteUserHandlerBase.HandleAsync, the shared account-erasure workflow in
MMCA.Common.Application
(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:137-149),
so every app that derives from the base gets the identical revocation window with no per-app code.
- The base requires the cache to do it.
ICacheServiceis a constructor parameter of the base (DeleteUserHandlerBase.cs:58-61), so an app cannot derive from it and silently skip the marker; the compiler asks for the dependency. - It runs after the commit and before the app's tail. The order is
SaveChangesAsync(:135), thenSoftDeletedUserCache.MarkDeletedAsync(cacheService, command.UserId, cancellationToken)(:142-144;SoftDeletedUserCache.MarkDeletedAsyncatSoftDeletedUserCache.cs:53-61), then the queuedafterCommitactions (:151-154). The base's own remarks give the reason (:31-36): the app's tail is unbounded work (deleting a blob, calling storage) that can be slow or throw, and every second it takes is a second the deleted account's token still works, so revoking first bounds the exposure to the cache round trip regardless of what the app queued behind it. - It is best effort, and deliberately so. A non-cancellation exception is caught and logged as a
warning via
UserUseCaseLog.SoftDeletedMarkerFailed(DeleteUserHandlerBase.cs:146-149;MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserUseCaseLog.cs:23) rather than failing an erasure that has already committed irreversibly. A failed write costs only the shortening: revocation falls back to the passive 30-second window, exactly as it behaved before the marker existed (DeleteUserHandlerBase.cs:24-29). - Apps are told not to write it themselves. The
afterCommitparameter's own documentation says the base already wrote the marker ahead of the tail, so a subclass must not queue a second write (DeleteUserHandlerBase.cs:175-181).
Both deployed apps therefore revoke at the same speed. After the 1.185.0 sweep MMCA.ADC's
DeleteUserHandler
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs)
keeps only its avatar-blob tail, its hand-rolled after-commit marker closure and its local
LogSoftDeletedMarkerFailed partial having been deleted in favour of the base's; MMCA.Store's
handler (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs)
keeps its linked-Customer cascade and gains the marker behavior purely by forwarding the new
ICacheService constructor parameter to the base. Neither app contains a marker write.
Rationale
- Bounds the stateless-JWT revocation gap cheaply. Stateless JWT (ADR-004) has no built-in revocation, so a deactivated account would otherwise stay usable for the full remaining token lifetime. A 30-second cached check turns "valid until the token expires" into "rejected within about 30 seconds," which is the point of the middleware.
- The cache is what keeps it stateless-friendly. Checking account status on every request would put a database read back in the hot path of every authenticated call, the cost stateless JWT was meant to avoid. Caching the boolean for 30 seconds keeps the lookup rate at most once per user per window, so the common case stays a cache hit.
- The validator abstraction keeps the middleware in the framework. The middleware lives in
MMCA.Common.APIand depends only onISoftDeletedUserValidator, so it needs no reference to any app'sUserentity. The query itself is generic over the app's aggregate (SoftDeletedUserValidator<TUser>), so each Identity module supplies only the type argument and the registration, not a copy of the query. - Lazy resolution is what makes it safe everywhere. One pipeline runs in Identity-hosting and non-Identity hosts alike; resolving the validator lazily lets the same middleware gate real requests where Identity lives and stay inert where it does not, without a per-host pipeline variant.
Trade-offs
- Revocation is bounded, not immediate. A soft-deleted user whose status is cached as not-deleted
keeps passing until that cache entry expires (up to 30 seconds), unless the deleting handler wrote
the marker itself. Shrinking the window costs more database lookups; lengthening it widens the
exposure. 30 seconds is the chosen balance, and it is a compile-time value
(
SoftDeletedUserCache.MarkerDuration,SoftDeletedUserCache.cs:29), not configurable per host today. - The marker only covers erasures that go through the shared base. The framework now both
supplies the key and TTL (
SoftDeletedUserCache.MarkDeletedAsync,SoftDeletedUserCache.cs:53-61) and calls it on the app's behalf (DeleteUserHandlerBase.cs:142-144), so no app can forget it. What the base cannot cover is a soft-delete that never reaches it: an administrativeIsDeleted = trueapplied by a migration, a support script or any handler other than aDeleteUserHandlerBasesubclass writes no marker, and those deletions still revoke only at the passive 30-second bound. Uniformity here is uniformity across the erasure use case, not across every path that can set the flag. - A cache fault silently degrades the window rather than failing. The write is swallowed and
logged at Warning (
DeleteUserHandlerBase.cs:146-149), which is the right trade for an already-committed irreversible erasure, but it means the shortened window is a best-effort property: the caller gets a success result either way, and only the log says whether revocation was actually accelerated. - Failing open is a deliberate availability-over-strictness trade. A cache or database failure on
this path lets the request through instead of rejecting it
(
SoftDeletedUserMiddleware.cs:93-100,:118-125), so while either store is unhealthy a deleted user keeps being served for the remaining lifetime of an already-issued access token. The alternative, failing closed, would convert the same blip into a 401 for every authenticated request in the application. - The no-op posture trusts upstream validation. In a service with no validator registered, a soft-deleted user's token is accepted for its full lifetime at that service, on the assumption that Identity is the source of truth and the token was validated upstream. Only Identity-hosting hosts actually enforce the revocation; extracted non-Identity services do not re-check.
- Cache-scope-dependent. The window is per cache scope: with a distributed cache the revocation is shared across replicas, but with a per-instance memory cache each instance carries its own 30-second window, so the effective revocation lag is per replica.
- Enforcement depends on registration. An Identity-hosting service that fails to register
ISoftDeletedUserValidatorsilently degrades to the no-op path, the same audit-the-inventory caveat other opt-in framework capabilities carry (ADR-005).
Related
ADR-005 (soft-delete is the deletion model whose still-authenticated tokens this middleware revokes;
deleting a user is a soft-delete, not a row removal), ADR-004 (the stateless RS256/JWKS validation
that has no built-in revocation, which this bounds without a per-request store lookup), ADR-022 (the
SSR session cookie carries the same JWT into the prerender path this middleware also gates), ADR-079
(the shared named-step HTTP pipeline that now owns this middleware's registration and its order),
ADR-073 (multi-tenancy, whose TenantResolutionMiddleware step sits between authentication and the
rate limiter, ahead of this one), ADR-019 (the rate limiter that runs immediately before it, for its
own authentication-dependent reason).
Revision (2026-08-07)
Re-verified against current source. The decision is unchanged, but three things it described have moved: the validator implementation, the home of the 30-second constant, and the claim that both apps revoke at the same speed.
- The per-app validators were hoisted into one shared generic. The ADR cited a
filter-bypassing existence query duplicated in each app
(
MMCA.ADC.Identity.Application/Users/SoftDeletedUserValidator.cs:10,21-24and the Store equivalent). Neither file exists now. The query lives once in the framework asSoftDeletedUserValidator<TUser>(MMCA.Common/Source/Core/MMCA.Common.Application/Users/SoftDeletedUserValidator.cs:20-34, its own remarks at:11-17stating no per-app subclass is needed), closed over each app'sUserat registration (MMCA.ADC.Identity.Application/DependencyInjection.cs:35, from:32;MMCA.Store.Identity.Application/DependencyInjection.cs:44, from:39). The behavior of the query is what it was; only its location changed. - The 30-second value moved out of the middleware. There is no
CacheDurationmember inSoftDeletedUserMiddlewareany more. The figure isSoftDeletedUserCache.MarkerDuration => TimeSpan.FromSeconds(30)(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:29), referenced by the middleware's cache write atSoftDeletedUserMiddleware.cs:132. The extraction is what lets a delete handler write the same key and TTL the middleware reads (SoftDeletedUserCache.cs:42-43,:53-61). - The fail-open policy is now recorded. The middleware wraps its cache read, validator query and
cache write in handlers that let the request proceed on failure
(
SoftDeletedUserMiddleware.cs:93-100,:118-125,:135-140), a trade-off argued in the class's own remarks (:16-30). The ADR asserted neither the behavior nor the reasoning before; both are now in the Decision and Trade-offs sections. - The uniform revocation window was wrong for ADC. ADC's
DeleteUserHandlerwrites the deleted marker in an after-commit callback (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:68-80), so its revocation lands on the next request rather than after the passive window. Store's handler does not (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:35-60), so Store is the app the original "at most 30 seconds" wording actually described. The asymmetry is stated in the Decision and carried as its own trade-off. - Line anchors re-verified. The middleware class declaration is at
SoftDeletedUserMiddleware.cs:31(from:15; lines 1-30 are usings plus the class remarks), the null-UserIdpass-through at:65-73(from:43-51), the lazy validator resolution at:75(from:53) with its no-validator pass-through at:76-83(from:54-61), the key build at:85and cache read at:91(from:63-66), the cached-true401 at:102-106(from:66-71), the cache-miss validator call at:114-116with the cache write at:131-133and the deleted-401 at:143-147(from:73-84), and the fall-through at:150(from:86). Unchanged and re-checked: the pipeline registration (WebApplicationExtensions.cs:109, with:96,108and:110around it, both superseded on 2026-08-23, see below),ISoftDeletedUserValidator.cs:7,15, the Helpdesk no-op path (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:111, superseded below), andMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Middleware/SoftDeletedUserMiddlewareTests.cs.
Revision (2026-08-23)
Re-verified against current source. The decision, the cache design, the fail-open policy and the per-app asymmetry are all unchanged; what moved is where the middleware's position is expressed, and one new neighbour now sits ahead of it.
- The registration left
WebApplicationExtensions.csentirely. That file no longer callsUseAuthentication,UseRateLimiter,UseMiddleware<SoftDeletedUserMiddleware>orUseAuthorizationat all:UseCommonMiddlewarePipeline(WebApplicationExtensions.cs:48, and the configure overload at:60) only routes into the privateApplyPipelinehelper (:140), which seedsMiddlewarePipelineBuilder.CreateDefault()(:142). Every:96,:108,:109and:110anchor the Decision and the 2026-08-07 revision carried is therefore dead. The step registrations now live inMMCA.Common/Source/Presentation/MMCA.Common.API/Startup/Pipeline/MiddlewarePipelineBuilder.cs:UseAuthenticationat:110,UseRateLimiterat:126,UseMiddleware<SoftDeletedUserMiddleware>()at:130,UseAuthorizationat:134. The behavior is what it was; the order became data in a named-step builder (ADR-079). - A tenant-resolution step now runs between authentication and rate limiting.
TenantResolutionMiddlewareis applied atMiddlewarePipelineBuilder.cs:118under theTenantResolutionstep name (ADR-073), so the current run of steps is Authentication (:110), TenantResolution (:118), RateLimiting (:126), SoftDeletedUserFilter (:130), Authorization (:134). The ADR's own claim, that the check runs after authentication and before authorization, still holds exactly; it is simply no longer adjacent toUseAuthentication. That new step follows the same opt-in, inert-unless-configured posture this middleware established, and its own comment names this middleware as the precedent (MiddlewarePipelineBuilder.cs:114-117). - The order is now held by a fitness function.
MiddlewarePipelineOrderTestsBase.ExpectedStepNames(MMCA.Common/Source/Hosting/MMCA.Common.Testing/Conformance/MiddlewarePipelineOrderTestsBase.cs:38-58) listsSoftDeletedUserFilterbetweenRateLimitingandAuthorization(:51-53), so a reorder fails a test. Note the limit:MiddlewarePipelineBuilder.Build()validates four load-bearing adjacencies at startup (MiddlewarePipelineBuilder.cs:257-280) and none of them names this step, so a host that moves it with the configure overload gets no startup error, only the fitness-function failure. - Helpdesk anchor corrected.
app.UseCommonMiddlewarePipeline()is atMMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:130, aftervar app = builder.Build();(:124) andapp.MapDefaultEndpoints()(:129); the:111and:117anchors earlier drafts carried are both dead. The substance is unchanged: Helpdesk wires the shared pipeline, registers noISoftDeletedUserValidator, and takes the no-op path. - Re-checked and unchanged:
SoftDeletedUserMiddleware.cs:31(class declaration),:65-73(anonymous pass-through),:75and:76-83(lazy resolution, no-validator pass-through),:85,:91,:102-106,:114-116,:131-133,:143-147,:150(the cache/validator path),:93-100,:118-125,:135-140(fail-open handlers),ISoftDeletedUserValidator.cs:7,15,SoftDeletedUserValidator.cs:20-34with its constraint at:21and query at:31-34,SoftDeletedUserCache.cs:29,42-43,53-61, the two registrations (MMCA.ADC.Identity.Application/DependencyInjection.cs:35,MMCA.Store.Identity.Application/DependencyInjection.cs:44), the two delete handlers (ADCDeleteUserHandler.cs:68-80, StoreDeleteUserHandler.cs:35-60), andMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Middleware/SoftDeletedUserMiddlewareTests.cs.
Revision (2026-09-03, MMCA.Common 1.185.0)
Re-verified against current source. The middleware, the cache design, the 30-second window and the fail-open policy are all unchanged. What changed is who writes the deleted marker, and with it the central asymmetry this record has carried since 2026-08-07.
- The marker write moved into the framework.
DeleteUserHandlerBase.HandleAsyncnow writes it itself, betweenSaveChangesAsyncand the app's post-commit tail (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:137-149, the call at:142-144). The base's remarks argue the ordering (:31-36): the app's tail is unbounded work, and every second of it is a second a deleted account's token still works, so the revocation goes first and the exposure is bounded by the cache round trip alone. ICacheServicebecame a required constructor parameter. The base is nowDeleteUserHandlerBase<TUser, TCommand>(IUnitOfWork unitOfWork, ICacheService cacheService, ILogger logger)(DeleteUserHandlerBase.cs:58-61). This is a breaking constructor change for every subclass, and it is the mechanism that makes the guarantee real: an app cannot derive from the base and quietly omit the marker, because the compiler asks for the dependency.- The window is uniform, so the "two apps revoke at different speeds" claim is retired. Item 4
of the 2026-08-07 revision and the trade-off that restated it are superseded. ADC's hand-rolled
after-commit closure and its local
LogSoftDeletedMarkerFailedpartial were deleted fromMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs, leaving only its avatar-blob tail; Store gained the behavior without writing any marker code, by adding anICacheServiceparameter toMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.csand forwarding it to the base. Both landed in the 1.185.0 consumer sweep. - Best effort is now a framework property rather than an app one. The base catches any
non-cancellation exception from the write and logs it at Warning through
UserUseCaseLog.SoftDeletedMarkerFailed(DeleteUserHandlerBase.cs:146-149;MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserUseCaseLog.cs:23), for the reason the remarks give (:24-29): the erasure is already committed and irreversible, so a cache fault must not turn it into a failure the caller would retry. On failure the revocation falls back to the passive 30-second bound. - Subclasses are told not to duplicate it. The
afterCommitparameter's documentation states that the base already wrote the marker ahead of the tail (DeleteUserHandlerBase.cs:175-181), so a second write from an app hook is a documented mistake rather than a silent duplicate. - Store registration anchor corrected.
ISoftDeletedUserValidatoris registered atMMCA.Store.Identity.Application/DependencyInjection.cs:44; the:46anchor the Decision and both earlier revisions carried is dead. ADC's is unchanged atMMCA.ADC.Identity.Application/DependencyInjection.cs:35. - Re-checked and unchanged:
SoftDeletedUserMiddleware.cs:31,:65-73,:75,:76-83,:85,:91,:102-106,:114-116,:131-133,:143-147,:150,:93-100,:118-125,:135-140,ISoftDeletedUserValidator.cs:7,15,SoftDeletedUserValidator.cs:20-34,SoftDeletedUserCache.cs:29,42-43,53-61, and the pipeline anchors inMiddlewarePipelineBuilder.cs(:110,118,126,130,134).