Architecture Decision Record
ADR-116: Identity Completions Ship as Opt-In Base Classes, Not as Framework Features
Status
Accepted (2026-09-09; revised 2026-09-19: item 10 now scopes the "no @page directive" claim to what
this decision ships, because MMCA.Common.UI itself ships 14 of them across 13 components in the
credential, notification and shell pages, and it now names UserAdminList<TUser> as the third
routeless administration component). Revised 2026-10-01 (item 10 now records that MMCA.Common.UI
ships the email confirmation page; see Revision below). Revised 2026-10-06: item 13 records versioned
Terms of Service acceptance as a fifth opt-in completion of the same shape (see Revision below).
Revised 2026-10-07: anchors refreshed after the v1.233.0 release.
Revised 2026-10-21: anchors refreshed after the v1.250.0 release.
Revised 2026-10-26: anchors refreshed after the v1.261.0 release.
Layers stored permission grants over
ADR-020's compiled registry and extends the shared sign-in
workflow of ADR-050 with optional collaborators.
Context
The framework already owns the hard, uniform parts of identity. AuthenticationServiceBase<TUser>
carries login, registration, refresh-token rotation with reuse detection and revocation
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:67), the apps
subclass it and supply hooks for the pieces that are genuinely theirs (their User factory, their
claim set, their lookups). Password reset ships the same way: a cache-backed, hashed-at-rest,
single-use token service
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:33,
ADR-091) plus two handler bases the apps derive from.
Authorization is a compiled role-to-permission map behind IPermissionRegistry
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionRegistry.cs:14,
ADR-020), consulted by the CQRS authorization decorators and
by the [HasPermission] policy handler.
Four things that a production identity module needs were still missing, and every consumer either lived without them or would have written its own: a second authentication factor, email confirmation, permission grants an operator can edit without a deploy, and a user and role administration API.
The obvious way to add them is as framework features: a concrete TwoFactorService with its own
tables, a confirmation flow with its own pages, an admin area. That shape is wrong for this
framework for three reasons the existing code already demonstrates.
The user row is the app's, not the framework's. Store's User
(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/User.cs) and ADC's differ in
their role model, their profile fields, their linked aggregates and their invariants. Every existing
identity hoist reaches the app's aggregate through a narrow contract (IAuthUser,
IPasswordChangeableUser, IErasableUser) precisely because a framework-owned user table would
either be too thin to use or force fields an app has no column for.
Sign-in is a chain no consumer can afford to have changed under it. ADC and Store both run their
production sign-in through the shared base. A new mandatory step in that chain is a behaviour change
in a deployed system, and the framework's own history says so: the 1.188.0 release needed an
UPGRADING.md section with eight items (MMCA.Common/UPGRADING.md:1447), one of which (the fallback
authorization policy, item 1 at :1453) broke unannotated endpoints.
Pages are where apps diverge most. The framework ships credential pages in MMCA.Common.UI, and
their existence is already the exception rather than the rule. A two-factor enrollment page has to
render a QR code, present recovery codes once, and fit an app's layout and copy; two consumers have
not yet asked for the same one.
Decision
The four identity completions ship as opt-in extension points: contracts and abstract bases in
Application, implementations in Infrastructure behind their own Add* calls, and OPTIONAL
constructor arguments where the shared sign-in workflow has to learn a new step. A consumer that
adopts none of them observes no change at all. Pages stay in the consumers until two of them want the
same one.
Two-factor is a contract plus a challenge, not a feature.
ITwoFactorService(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorService.cs:16) is stateless cryptography: secrets, provisioning URIs, code verification inside a configured skew window, and recovery codes returned as aRecoveryCodeSetof plaintext plus hashes (:97).ITwoFactorStore(.../Auth/TwoFactor/ITwoFactorStore.cs:24) is the persistence the consumer implements over its ownUser, which exposesITwoFactorUserState(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ITwoFactorUserState.cs:27). The framework ships no user table and no migration for this.The sign-in hook is an optional constructor argument, and its absence is a no-op.
AuthenticationServiceBasegainedITwoFactorAuthenticator? twoFactor = nullandIOptions<EmailConfirmationSettings>? emailConfirmationSettings = null(.../Auth/AuthenticationServiceBase.cs:73-74), the optional trailing-parameter shapeChangePasswordHandlerBaseuses for itsTimeProvider? timeProvider = null(.../Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:62).ChallengeSecondFactorAsync(:550) answersNotEnrolledoutright when no authenticator was supplied (:554-555), so an unadopted consumer pays not even a query, and every existing subclass keeps compiling because it simply passes fewer arguments.The step-up assertion is a claim, and presence is the whole test. A verified challenge stamps the
amr-stylemfaclaim (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:62) with the method that satisfied it (:65,:68), through the same pass-through token wrapper that already stampssid:CreateAccessTokenForSession(.../Auth/AuthenticationServiceBase.cs:515-516) hands the app's token toAuthSessionIssuer.MintForSession, which appendssidandmfa(.../Auth/Sessions/AuthSessionIssuer.cs:587,:592), so the app'sCreateAccessTokenhook keeps its signature.IRequiresMfa(.../UseCases/Markers/IRequiresMfa.cs:28) is checked by the decorators through the sharedAuthorizationGate(.../UseCases/Decorators/AuthorizationGate.cs:60), which reads the claim viaClaimsPrincipalExtensions.HasMultiFactor(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:127) rather than wideningICurrentUserService. Absence denies; there is no "this account has no second factor so let it through" branch.Email confirmation reuses the password-reset design and gates nothing by default.
IEmailConfirmationTokenService(.../Auth/EmailConfirmation/IEmailConfirmationTokenService.cs:15) mirrorsIPasswordResetTokenServicemember for member, and the implementation (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:41) mirrors its record shape, hashing, attempt cap and throttle, under its ownemailconfirm:key prefix so issuing a confirmation link cannot invalidate an outstanding reset link.RequireConfirmedEmaildefaults to false (.../Auth/EmailConfirmation/EmailConfirmationSettings.cs:55, under theAuthentication:EmailConfirmationsection at:13), because turning it on locks out every existing account whose address was never confirmed. The existingIEmailSenderis reused; no mail abstraction ships with this.Stored grants union with the compiled registry and can never deny.
PermissionGrant(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/PermissionGrant.cs:24) is a flat(Role, Permission)row with no soft-delete flag (.../Infrastructure/Persistence/Auth/PermissionGrantModelBuilderExtensions.cs:30). The DI call is the whole opt-in:AddStoredPermissionGrants(configuration)registers the markerPermissionGrantModelGate(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/PermissionGrantModelGate.cs:19,.../Infrastructure/DependencyInjection.Auth.cs:147), andApplicationDbContextresolves that marker withGetServiceand maps the table only when it is present AND this context instance targets the physical source named byAuthentication:PermissionGrants:DataSourceName(.../Persistence/DbContexts/ApplicationDbContext.cs:920-925, applied at:943-951). No consumer calls the model-builder extension by hand, a host that never opts in keeps a byte-identical model, and the other databases in an opted-in host stay unchanged because one database owns the rows (the refresh-session precedent).LayeredPermissionRegistry(.../Auth/Permissions/LayeredPermissionRegistry.cs:30) decorates whateverIPermissionRegistrythe host already registered and adds the stored set to the compiled one. There is no deny row: a stored edit can only widen a role, so the effective permission set never depends on evaluation order and a data change can never disable an endpoint the code guarantees.The authorization read stays synchronous, so the cache is part of the contract.
IPermissionRegistry.HasPermissionis synchronous and sits on the hot path of every gated request. Rather than block on I/O there, the grants are held as a per-roleIMemoryCachesnapshot behindIPermissionGrantCache(.../Auth/Permissions/IPermissionGrantCache.cs:21), rebuilt by a hosted service onAuthentication:PermissionGrants:CacheSeconds(.../Auth/Permissions/PermissionGrantSettings.cs:12,:25) and immediately byIPermissionGrantCacheInvalidator(.../Auth/Permissions/IPermissionGrantCache.cs:53) after an edit. A cold cache grants nothing, which is the safe direction, and the compiled layer answers from the first request either way.The administration API is two controller bases over two services, one of which ships filled in.
UsersAdminControllerBase<TUserDto>(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs:43) takes a consumer-implementedIUserAdministrationService<TUserDto>(.../Auth/Administration/IUserAdministrationService.cs:25), because only the app knows its user table.RolesAdminControllerBasetakesIRoleAdministrationService(.../Auth/Administration/IRoleAdministrationService.cs:29), which the framework CAN implement in full because both halves are framework-owned, soAddStoredPermissionGrantsregisters a default. Both bases are gated on capabilities, not role names (.../Controllers/Administration/UsersAdminControllerBase.cs:42,.../Controllers/Administration/RolesAdminControllerBase.cs:60), againstAdministrationPermissions.ManageUsers/ManageRoles(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/AdministrationPermissions.cs:18,:21). The roles base serves three reads and one write, the third read being the catalog an editor draws from:GetCatalogAsyncon the literal routecatalog, which takes precedence over the{role}template so no role named "catalog" can shadow it (.../Controllers/Administration/RolesAdminControllerBase.cs:98,:102), returningPermissionCatalogResponse(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/PermissionCatalogResponse.cs:22) overIRoleAdministrationService.GetCatalogAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IRoleAdministrationService.cs:58). A consumer routes its subclass atAdmin/Roles, so the catalog read isGET Admin/Roles/catalog.Three separate DI calls, none of them in
AddInfrastructure.AddTwoFactorAuthentication(config)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:40),AddEmailConfirmation(config)(:68) andAddStoredPermissionGrants(config)(:139) are opted into one at a time. Registering a service is still not the same as changing behaviour: two-factor only reaches sign-in once the app passes the resolved authenticator to its base constructor, and confirmation only gates sign-in onceRequireConfirmedEmailis set AND the app'sUserimplementsIEmailConfirmableUser(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IEmailConfirmableUser.cs:22).One new package, in Infrastructure only.
Otp.NET(MIT, netstandard2.0, no transitive graph) supplies the RFC 6238 grammar and the Base32 alphabet, which the BCL has no primitive for. It is referenced byMMCA.Common.Infrastructurealone (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/MMCA.Common.Infrastructure.csproj:74, used by.../Auth/TwoFactor/TotpTwoFactorService.cs:35); Application, Domain and Shared stay dependency-free, matching theCronosprecedent.Pages stay with the consumers; the administration surface ships as routeless components. No two-factor enrollment page ships in
MMCA.Common.UI, and nothing this decision ships carries an@pagedirective. The email confirmation landing page is the one page since promoted:MMCA.Common.UIships it anonymous at/confirm-email(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ConfirmEmail.razor:1-3). (The package itself does ship routable pages: the credential and notification screens of the Context above, that confirmation page, and theHome,ForbiddenandNotFoundshell pages, 14@pagedirectives across 13 components, fromMMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:1to.../Pages/Notifications/NotificationInbox.razor:1-2. Apart from that confirmation page, none of them belongs to this decision.)RoleAdminList(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/RoleAdminList.razor.cs:28),RoleAdminEdit(.../Pages/Administration/RoleAdminEdit.razor.cs:36) andUserAdminList<TUser>(.../Pages/Administration/UserAdminList.razor.cs:44) are components the app routes, authorizes and links for itself (RoleAdminList.razor.cs:13-14). They were promoted under the rule the framework already applies to shared components, two consumers wanting the same one. The two role components are non-generic because roles and permissions are strings the framework already owns, so there is no app DTO to name (.../Presentation/MMCA.Common.UI/DependencyInjection.cs:300-301); the roster is generic instead, over an app-owned DTO that only has to implementIUserAdminDTO(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Administration/IUserAdminDTO.cs:25), which is the one place a routeless component still names app shape. The role pair talks to the controller base throughIRoleAdminUIService(.../UI/Services/Administration/IRoleAdminUIService.cs:22) and its typed-client implementationRoleAdminService(.../UI/Services/Administration/RoleAdminService.cs:25-28), registered byAddRoleAdministrationUI()(.../UI/DependencyInjection.cs:307); the roster registers throughAddUserAdministrationUI<TUserDto>()(.../UI/DependencyInjection.cs:281). An app that serves no role-administration endpoints registers nothing and renders neither role component, and an app that serves noAdmin/Usersendpoints never renders the roster.The editor draws a closed catalog, and the surface cannot be locked out from inside it.
IPermissionCatalog(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionCatalog.cs:24) is the compiled universe an administration screen may offer: every role the registry grants something to and every permission its code can grant, both sorted ordinally. It is implemented explicitly byPermissionRegistry(.../Shared/Auth/Permissions/PermissionRegistry.cs:16,:55,:58), so the same frozen map answers both questions while the hot authorization path keeps a registry that deliberately cannot enumerate;AddPermissionsregisters the one instance under both contracts (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:135-136), and an unconfigured host falls back toUnconfiguredPermissionRegistry(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/UnconfiguredPermissionRegistry.cs:20-21, registered at.../Application/DependencyInjection.cs:123,:129).SetStoredPermissionsAsyncrefuses two things against that list (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs:147-154,:156-168):AdministrationPermissions.ManageRoles, outright, because granting the key to this surface from a row would make access to role administration a matter of data and deleting the row would lock every operator out of the screen that could restore it; and any permission outside the catalog, because a stored row no endpoint checks is a typo rather than a silently inert grant.The token carries the permissions, so a service that never sees the grants still honours them.
TokenServicetakes the host'sIPermissionRegistry(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:73) and emits oneAuthClaimTypes.Permissionclaim (permission,MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:24) per permission granted to the token's role, ordinally ordered and de-duplicated against claims the caller already supplied (TokenService.cs:133-141); because the registry it resolves is the layered one, stored grants are baked in alongside the compiled ones. Both authorization paths accept that claim: the CQRSAuthorizationGatepasses a request whose permission the registry grants to the caller's roles OR that the principal carries as a claim (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/AuthorizationGate.cs:46-48, overClaimsPrincipalExtensions.HasPermissionClaim,.../Shared/Auth/ClaimsPrincipalExtensions.cs:94), and the UI gates a navigation entry onNavItem.RequiredPermission(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavItem.cs:20,.../UI/Layout/NavMenu.razor:259). The deployment consequence is a design rule, not framework code: where modules run as separate services, the host that mints tokens (the Identity service) registers every module's compiled grants so a token carries the full permission set, and the stored grants live only in that host, reaching the other services through the claims alone. Consumers therefore declare each module's role-to-permission grants in that module's Shared project and register them in both the module's own host and the Identity host. A permission change takes effect on the next sign-in, exactly like a role change.Versioned Terms of Service acceptance is a fifth completion of the same shape.
AddLegalAcceptance(configuration)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:104) is a fourth separate DI call, like the three of item 8 called by nothing in the framework: it binds onlyLegalAcceptanceOptions(sectionLegal,CurrentTermsVersion), deliberately registers noILegalAcceptanceService(:93-95), and with no version configured changes nothing. The app'sUserimplementsILegalAcceptingUser(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ILegalAcceptingUser.cs:21), the app supplies the service, and it routes a subclass ofLegalAcceptanceControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:45). The version is the server's: at registration the client sends only anAcceptedTermsflag and the server stamps its own configured version (MMCA.Common/CHANGELOG.md:764), aPOSTnaming any other version is refused withLegal.VersionNotCurrent(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptancePolicy.cs:33), and both controller answers pass throughLegalAcceptancePolicy.Normalize(:47), which re-derives the standing against the configured version so a consumer's ownIsCurrentis never trusted. In the UI,TermsAcceptanceGate(MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Legal/TermsAcceptanceGate.razor.cs:29) is a layout component the host adds itself; its dialog cannot be dismissed (no backdrop click, Escape, close on navigation or close button,:39-42), so a user whose accepted version is no longer current re-accepts or signs out. It shipped in 1.223.0 (MMCA.Common/CHANGELOG.md:758). MMCA.ADC adopts it (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:248,MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:36,.../Identity.API/Controllers/LegalAcceptanceController.cs:28,.../Identity.UI/IdentityUIModule.cs:33); MMCA.Store and MMCA.Helpdesk do not.
Rationale
Six shapes were considered for these four capabilities, and each rejection is what produced the opt-in posture above:
- Ship a framework
Userentity with two-factor and confirmation columns. Rejected for the reason ADR-032 rejected it for password material: the two deployed apps model users differently, and a framework table would either be ignored or force a migration nobody wants. The narrow-contract approach (IAuthUserand friends) is already proven across both apps. - Make the second factor a mandatory step in
AuthenticationServiceBase, off by configuration. Rejected: a mandatory step means the query and the code path run for every consumer, and a misconfiguration becomes a sign-in outage. An optional collaborator that is null makes the unadopted case unreachable rather than merely disabled. - Add a
HasMultiFactormember toICurrentUserService. Rejected: the claim is already readable through the principal the interface exposes, and every implementation of that interface, including the hand-written test doubles, would have had to grow a member. - Let a stored grant deny a permission. Rejected: a deny row makes the effective permission set depend on evaluation order and lets a data edit silently disable an endpoint the code guarantees. Removing a compiled-in capability stays a code change.
- Load stored grants per role, on demand, inside
HasPermission. Rejected: the contract is synchronous, so this means blocking I/O on the hot path of every permission-gated request. A snapshot with explicit invalidation trades a bounded staleness window for no I/O at all. - Ship concrete administration controllers rather than bases. Rejected for the reason
DataExportControllerBaseis a base: a concrete controller cannot construct the app's DTO or route itself into the app's URL space.
Trade-offs
- A consumer that adopts nothing sees no behaviour change and no new configuration. The only edit that
reaches an unadopted consumer is the widened
AuthenticationServiceBaseconstructor, which is source-compatible (the new parameters are optional) and lands in the same release as the packages it ships with. - Each piece is adopted separately, and adopting one does not drag in another. Two-factor works without stored grants, stored grants work without the administration API, and the API works with the compiled registry alone.
- Every consumer that wants two-factor writes one class:
ITwoFactorStoreover its ownUser, plus the columns behindITwoFactorUserState. That is deliberate duplication, and it is the same trade the framework already made forFindUntrackedByEmailAsync: a framework-owned user table would cost more than the class it saves. - Stored grants are per-process cached, so an edit made on one replica is live there immediately and
reaches the others within
CacheSeconds(default 300). A cross-replica push would put a broker on the authorization path; the window it would close is a grant taking effect a few seconds late, never a revoked grant outliving the interval. EFPermissionGrantStorehard-deletes a revoked grant, so it joins the framework's short soft-delete allowlist (ADR-005). Consumers running the same fitness rule against the packages have to add the type to their ownAllowedHardDeleteTypeswhen they upgrade.- Adopting the
PermissionGrantstable is a migration in the consumer's Identity database, asRefreshSessionswas.AddStoredPermissionGrants(configuration)is what maps it, and only in the context whose physical sourceAuthentication:PermissionGrants:DataSourceNamenames (.../Persistence/DbContexts/ApplicationDbContext.cs:920-925), so a host that never opts in gets no table in any of its databases and an opted-in host gets it in exactly one. - A permission granted by a stored row reaches another service only through a token claim, so it lands
on the holder's next sign-in rather than within
CacheSeconds. That is the same latency a role change has always had, and it is the price of keeping the grant table in one host instead of replicating it.
Revision (2026-10-01)
Item 10 said no confirmation page ships in MMCA.Common.UI. One now does: ConfirmEmail.razor
carries @page "/confirm-email" and [AllowAnonymous]
(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ConfirmEmail.razor:1-3), added by
MMCA.Common #443 (1.211.0), so the package carries 14 @page directives across 13 components, and
item 10 and Status now say so. No two-factor enrollment page ships, and the opt-in posture of the
four completions is unchanged. Item 2 named IRefreshSessionStore on ChangePasswordHandlerBase
as the optional-argument precedent; that parameter is now required
(.../Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:46), so item 2 now cites the
optional TimeProvider at :48. Citations refreshed: the three Add* calls and the grant-model gate
registration now live in DependencyInjection.Auth.cs (:39, :67, :108, :116), the gate logic
is ApplicationDbContext.cs:916-921, the 1.188.0 section is UPGRADING.md:532, and the remaining
anchors in the Context and items 1 to 12 point at their current lines.
Revision (2026-10-06)
- Item 13 is new: versioned Terms of Service acceptance (MMCA.Common 1.223.0) is recorded as a fifth
opt-in completion of the same shape, with its own
AddLegalAcceptancecall, an app-implementedILegalAcceptingUserand service, a server-owned version, and a non-dismissable re-acceptance dialog. MMCA.ADC adopts it; MMCA.Store and MMCA.Helpdesk do not. - Status said
MMCA.Common.UIships 13@pagedirectives in the credential and notification pages, although the 2026-10-01 Revision above says Status had been updated. It now says 14 across 13 components, the shell pages included, which is what item 10 already said. - The 2026-10-01 Revision's anchors have moved since: the three
Add*calls are nowDependencyInjection.Auth.cs:40,:68and:136and the gate registration:144; the gate logic isApplicationDbContext.cs:913-918, applied at:936-944; and the 1.188.0 section isUPGRADING.md:803. - Line anchors in the Context, items 2, 3, 5, 8, 9, 10, 11 and 12, and the Trade-offs were re-verified against current source and refreshed.
Revision (2026-10-07)
Re-verified against current source. The decision, the five opt-in completions and their behaviour
are unchanged: AddStoredPermissionGrants still registers the PermissionGrantModelGate marker,
and ApplicationDbContext still maps the grant table only when that marker resolves AND the context
targets the source named by Authentication:PermissionGrants:DataSourceName. Only line anchors
moved.
- Anchors re-verified against current source:
AddStoredPermissionGrantsisMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:139and the gate registration:147(AddTwoFactorAuthentication:40,AddEmailConfirmation:68,AddLegalAcceptance:104and the no-ILegalAcceptanceServicenote:93-95are unchanged); the gate logic isMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:914-919, applied at:937-945;IPermissionGrantCacheInvalidatorisMMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/IPermissionGrantCache.cs:53; and in item 13 the server-stamped version isMMCA.Common/CHANGELOG.md:230and the 1.223.0 release isMMCA.Common/CHANGELOG.md:224. Items 5, 6, 8 and 13 and the Trade-offs now cite these.
Revision (2026-10-21)
Re-verified against current source. The decision, the five opt-in completions and their behaviour
are unchanged: ChangePasswordHandlerBase still takes TimeProvider? timeProvider = null as its
optional trailing parameter, ApplicationDbContext still maps the grant table only when the
PermissionGrantModelGate marker resolves AND the context targets the source named by
Authentication:PermissionGrants:DataSourceName, and MMCA.ADC still adopts Terms of Service
acceptance while MMCA.Store and MMCA.Helpdesk still do not. Only line anchors moved.
- Anchors re-verified against current source: the 1.188.0 section is
MMCA.Common/UPGRADING.md:1219and its item 1:1225; the optionalTimeProvideris.../Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:62; the gate logic isMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:920-925, applied at:943-951;UsersAdminControllerBaseisMMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs:43with its capability gate at:42, andIUserAdministrationServiceis.../Auth/Administration/IUserAdministrationService.cs:25;Otp.NETis referenced atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/MMCA.Common.Infrastructure.csproj:82;UserAdminList<TUser>is.../Pages/Administration/UserAdminList.razor.cs:44,IUserAdminDTOisMMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Administration/IUserAdminDTO.cs:25, and inMMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.csthe non-generic rationale is:300-301,AddRoleAdministrationUI:307andAddUserAdministrationUI<TUserDto>:281; in item 13 the server-stamped version isMMCA.Common/CHANGELOG.md:549and the 1.223.0 release:543, and MMCA.ADC registersAddLegalAcceptanceatMMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:242andTermsAcceptanceGateatMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/IdentityUIModule.cs:33. The Context, items 2, 5, 7, 9, 10 and 13 and the Trade-offs now cite these.
Revision (2026-10-26)
Re-verified against current source. The decision, the five opt-in completions and their behaviour
are unchanged: the two optional sign-in collaborators still default to null and an absent
authenticator still answers NotEnrolled without a query, Otp.NET is still referenced by
MMCA.Common.Infrastructure alone, SetStoredPermissionsAsync still refuses ManageRoles and any
permission outside the catalog, and MMCA.ADC still adopts Terms of Service acceptance. Only line
anchors moved.
- Anchors re-verified against current source:
AuthenticationServiceBaseisMMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:67, its optional arguments:73-74,ChallengeSecondFactorAsync:550and its null-authenticator branch:554-555; thesidandmfastamping runs throughCreateAccessTokenForSession(:515-516) intoMMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:587,:592;EmailConfirmationTokenServiceisMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:41;Otp.NETis referenced atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/MMCA.Common.Infrastructure.csproj:74; the two refusals areMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs:147-154and:156-168; the 1.188.0 section isMMCA.Common/UPGRADING.md:1447and its item 1:1453; in item 13 the server-stamped version isMMCA.Common/CHANGELOG.md:764and the 1.223.0 release:758, and MMCA.ADC registersAddLegalAcceptanceatMMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:248and declaresILegalAcceptingUseratMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:36. The Context and items 2, 3, 4, 9, 11 and 13 now cite these.
Related
ADR-020 (the compiled role-to-permission registry stored
grants layer over),
ADR-091 (the token-service design email confirmation mirrors),
ADR-032 (the narrow-contract precedent for keeping credential material in
the app's own user row),
ADR-050 (the sign-in and token-rotation pipeline the mfa claim
rides),
ADR-005 (the soft-delete allowlist the grant store joins).