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

Onboarding guide

24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)

What this chapter covers. This is the Identity bounded context of MMCA.ADC, the module that owns who a person is across every ADC surface: web, WebAssembly, and MAUI. It is a leaf in the module dependency graph (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModule.cs:11) but it touches every layer end to end, so this chapter doubles as a compact tour of one full vertical slice built on the framework taught in groups 1 through 15. The single aggregate is User, and around it sit the credential lifecycle, the role vocabulary (RoleNames), the change-password / password-recovery / email-confirmation / change-preferences / avatar use cases, the administration surface an operator works from (lock, unlock, change the role an account holds, edit what a role may do), the two privacy use cases that make ADC compliant (data-subject export and erasure), the persistence and EF configuration, the REST controllers, the gRPC contract that lets a peer service ask Identity a question, the integration events that keep the User-to-Speaker link consistent across the service split, and the Blazor profile, user-administration and role-administration UI. The per-type sections follow; this overview shows how the pieces fit and how a request flows through them.

Almost everything here is an instantiation of upstream framework machinery, cross-referenced rather than re-taught (the conventions themselves are introduced once in the primer): the Result pattern (G01), the AuditableAggregateRootEntity<TIdentifierType> entity chain plus the IAnonymizable and PiiAttribute governance markers (G02), the outbox spine with BaseIntegrationEvent (G04), the CQRS command/query handler pipeline (G05), the shared auth engine (AuthenticationServiceBase<TUser>, RoleValue, HasPermissionAttribute, IRefreshSessionStore, SoftDeletedUserCache, IPasswordResetTokenService) from G08, and the hoisted user use-case bases from G14 (ChangePasswordHandlerBase<TUser, TCommand>, ForgotPasswordHandlerBase<TUser, TCommand>, ResetPasswordHandlerBase<TUser, TCommand>, GetUserPreferencesHandlerBase<TUser>, DeleteUserHandlerBase<TUser, TCommand>, ExportUserDataHandlerBase<TUser, TQuery>) alongside the IModule composition system. The lenses this chapter most strongly embodies are [Rubric §4, Domain-Driven Design] (a behavior-rich aggregate that guards its own invariants), [Rubric §11, Security] (credential handling, RS256 JWTs, permission-based authorization, a fail-closed OAuth link gate, single-use reset tokens), and [Rubric §30, Compliance / Privacy / Data Governance] (the export and erasure flows). The // BR-NN markers quoted below are the in-code business-requirement references, catalogued in the ADC business-requirements guide; the privacy promises they implement live in MMCA.ADC/PRIVACY.md.

Projects, one bounded context

The module is split along the standard Clean Architecture layering ([Rubric §3, Clean Architecture]), each project pinned by a trivial AssemblyReference / ClassReference anchor pair (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/AssemblyReference.cs:5, :11) that Scrutor scanning and the architecture-fitness tests use to name the assembly. MMCA.ADC.Identity.Domain holds the User aggregate (User.cs:34), the UserRole value type (UserRole.cs:18), the UserInvariants rule class (UserInvariants.cs:9), and the UserDeleted (UserDeleted.cs:10) / UserPasswordChanged (UserPasswordChanged.cs:9) domain events; it depends only on MMCA.Common.Domain and MMCA.Common.Shared and knows nothing of EF or ASP.NET. MMCA.ADC.Identity.Application holds the use-case handlers, the Mapperly-generated UserDTOMapper (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/DTOs/UserDTOMapper.cs:14, which excludes the credential fields from the UserDTO projection, :11, ADR-001), the FluentValidation validators RegisterRequestValidator and ChangePasswordRequestValidator, and the cross-module service implementations; its DependencyInjection registers six services explicitly, among them the shared SoftDeletedUserValidator<TUser> closed over User, UserAdministrationService as the module's IUserAdministrationService<UserAdminDTO>, and LegalAcceptanceService as its ILegalAcceptanceService, neither of which the convention scan would pick up (MMCA.ADC.Identity.Application/DependencyInjection.cs:36-47), contributes the two export sections in the order they appear in the exported document (:53-54), and leaves handlers, mappers, validators, and domain-event handlers to ScanModuleApplicationServices<ClassReference>() (:58). MMCA.ADC.Identity.Infrastructure holds the ModuleApplicationDbContext (MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15), the UserConfiguration EF mapping, and the IdentityModuleDbSeeder; its own registration hook is deliberately a no-op, kept only so every module has the same shape (MMCA.ADC.Identity.Infrastructure/DependencyInjection.cs:20). MMCA.ADC.Identity.API holds the REST controllers, the IdentityModule descriptor (IdentityModule.cs:13), and the IdentityErrorResources anchor whose .resx siblings translate domain error codes into the supported languages (IdentityErrorResources.cs:11, rationale at :3-9, ADR-027). MMCA.ADC.Identity.Shared is the contract package every other layer (including the WebAssembly client) can reference without dragging in the domain: it carries the DTOs (UserDTO at UserDTO.cs:8, UserListDTO at UserListDTO.cs:7, UserAvatarDTO at UserAvatarDTO.cs:6, and the export family headed by UserDataExportSubjectDTO at UserDataExportSubjectDTO.cs:16), the IAttendeeQueryService cross-module interface (MMCA.ADC.Identity.Shared/Users/IAttendeeQueryService.cs:11, tagged [ServiceContract] at :10), the UserRegistered and UserDeleted integration events, and the IdentityPermissions / IdentityPermissionGrants capability constants and grant map (MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:10, IdentityPermissionGrants.cs:17). MMCA.ADC.Identity.UI sits in the same module folder and holds the Blazor pages; two further projects live under MMCA.ADC/Source/Services/ instead, because they exist only for the extracted topology: MMCA.ADC.Identity.Contracts (the gRPC adapter) and MMCA.ADC.Identity.Service (the extracted process host). The identifier alias for this context is UserIdentifierType = int, a database-generated identity (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2), while the cross-context LinkedSpeakerId is a nullable SpeakerIdentifierType (User.cs:78, ADR-048).

User (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:34) is the only aggregate root in the module, and it carries more responsibility than most: it is the credential store (PasswordHash plus a per-user PasswordSalt, both byte[] mapped to varbinary(max), User.cs:64, :67), the profile (Email, FirstName, LastName, each marked [Pii], User.cs:52, :56, :60), the preference store (PreferredCulture / PreferredTheme, :108, :111, ADR-027 / ADR-028), the avatar URL holder (:120, also [Pii], BR-116a, ADR-045), the optional MAUI device-metadata bag (:81-99), the external-OAuth link (LoginProvider / ProviderKey, :102, :105), and the 1:1 cross-context LinkedSpeakerId pointing at a Conference speaker (:78, BR-207 / BR-208 / BR-209). Note what the aggregate deliberately does not hold: refresh tokens are not part of it. They are per-device rows in the framework's RefreshSessions table, hashed at rest (BR-205 / BR-206, stated on the type itself at User.cs:15-16, ADR-097). Every property has a private setter, so state changes only through the aggregate's own methods: encapsulation as a compile-time guarantee ([Rubric §4, Domain-Driven Design], [Rubric §1, SOLID]). Its interface list is what binds the aggregate to the shared G08 user workflows, IPasswordChangeableUser, IUserPreferences, and IErasableUser (User.cs:34-35), joined by IEmailConfirmableUser for the confirmation flag (IsEmailConfirmed, :48) and ILegalAcceptingUser for the accepted Terms of Service version and instant (AcceptedTermsVersion, :138, TermsAcceptedOn, :144), both listed at :35; IErasableUser is declared on this type rather than inherited, and the class comment explains why it has to be (:19-25): Delete() hides the base soft-delete with new (:485), so only re-listing the interface here re-maps the workflow onto this type's own member and keeps the UserDeleted domain event in the shared erasure path. The last interface in the list, IAuditedEntity (:35), is the one non-behavioral marker: it opts the aggregate into a recorded change history, because an account record is where a support or compliance question is actually asked (who changed this attendee's email, when was this account raised to Organizer) and the plain audit fields answer only "who touched it last" (:27-30).

It follows the standard framework shape: a private EF constructor (User.cs:173), a private state constructor (:183), and static factory methods returning Result<T>. Create (:216) validates every invariant with Result.Combine(...) before constructing anything, so an invalid user is unrepresentable; CreateExternal (:264) builds an OAuth account with empty credential arrays and, when the provider asserts a verified address, marks it confirmed on the spot (:288). A subtlety worth carrying forward: the factory deliberately does not raise a registration event. The Id is database-generated ([IdValueGenerated] at :33, Id set to default until EF assigns it), so the cross-module UserRegistered is raised by the application layer only after the insert has executed and a real id exists (AuthenticationService.cs:188-191 does exactly that). The behavior methods each guard their own rule: ChangePassword re-validates and raises UserPasswordChanged (:402, event at :416); UpdatePreferences validates against the supported-culture allowlist and the light/dark theme values (:372); AcceptTerms (:314) validates the version through UserInvariants.EnsureTermsVersionIsValid (:316) and is idempotent for the version already on record (:322); Delete() calls the G02 soft-delete and raises UserDeleted (:485, event at :490), and it does not need to touch outstanding sessions, because the refresh flow re-fetches the account through the soft-delete query filter, so every session stops working the moment the delete commits (remarks on Delete).

The Domain project carries one more type that is not part of the aggregate. SyntheticAccounts (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/SyntheticAccounts.cs:20) names the reserved email domain the conference-day k6 load test registers its accounts under, loadtest.adc.invalid (:23), and classifies an address with IsSynthetic, trimmed and case-insensitive (:30-31). Those accounts are ordinary Attendees on every request path, so the test measures the real login and bookmark paths, but the two side effects that would leak outside the test skip them: registration schedules no confirmation email (MMCA.ADC.Identity.API/Controllers/AuthController.cs:76), so a run never spends the SMTP relay's quota or sender reputation, and AttendeeQueryService leaves them out of the attendee list that organizer broadcasts fan out to (AttendeeQueryService.cs:26). The domain sits under the RFC 2606 .invalid name, which can never be deliverable, so no real attendee can match the rule, and the k6 script spells the same domain, so the two change together (SyntheticAccounts.cs:3-19).

UserInvariants (UserInvariants.cs:9) is the co-located static rule class whose methods each return a Result, several of them delegating to the shared CommonInvariants (UserInvariants.cs:40-46). Centralizing each rule as a named, side-effect-free method is what makes the domain exhaustively unit-testable ([Rubric §14, Testability]), and its const length limits (FirstNameMaxLength = 100, LastNameMaxLength = 100, EmailMaxLength = 100, DeviceFieldMaxLength = 256, UserInvariants.cs:12-21) are the same constants UserConfiguration uses for the EF column widths (UserConfiguration.cs:22, :29, :33) and RegisterRequestValidator uses for the request-shape rules (MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:16-19), so the domain rule, the request contract, and the schema cannot drift apart. UserRole (UserRole.cs:18) is a value object over the shared RoleValue base that fixes the ADC role set to three members: Organizer (:21), Attendee (the registration default, :24), and ContentEditor (:31), each wrapping the matching RoleNames constant so the stored value and the JWT claim spell the role identically, a strict capability subset that curates the session catalog but cannot change event structure, rooms, feedback questions, run session selection, or read the user list (:26-31). Its IsOrganizer(string?) helper (:76) does a case-insensitive compare, because raw JWT role claims may carry any casing, and it is the exact check the delete and export authorization gates use.

Authentication: a thin subclass over the shared engine

The login / registration / refresh / revocation workflow is not re-implemented here. It lives in AuthenticationServiceBase<TUser> (G08), which owns the validate-first flow, the lockout and rate-limit protection (ADR-029), and the BR-205 / BR-206 refresh-session rotation with reuse detection (ADR-050); the ADC subclass documents that division of labor at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:17-49. AuthenticationService (AuthenticationService.cs:50) fills in only the context-specific pieces: CreateUser supplies the Attendee default role (BR-45, :139-146), CreateAccessToken attaches the speaker_id claim when the user is linked to a speaker (BR-209, :149-150 via the SpeakerClaims helper at :383), OnUserRegisteredAsync raises the UserRegistered integration event (:166-169), ValidateLoginCandidateAsync and ValidateRefreshCandidateAsync refuse an account an administrator has locked, after the password check so the distinct message cannot be read by anyone sweeping addresses (:96-120), and ExternalLoginAsync drives the OAuth find-by-provider, else link-by-email, else create flow (:192, body at :204, ADR-036); that flow applies the same administrator lock to both existing-account branches before anything is saved, so the lock binds every sign-in path and not only the password and refresh hooks (:284-295). Session issuance is deliberately not among its constructor dependencies: access-token minting, refresh-session rotation and the per-user session cap live in the framework IAuthSessionIssuer that AddInfrastructure registers, so the token lifetimes and the cap are configuration (the Jwt and RefreshSessions sections) rather than arguments of this service (:56, :63, rationale at :43-48). The shared AuthenticationValidators bundle (:55) and IOptions<EmailConfirmationSettings> (:57) are handed straight to the base as well (:62, :65).

Three details in that class are worth reading closely. First, atomicity: RegisterAsync wraps the whole base workflow in UnitOfWork.ExecuteInTransactionAsync (AuthenticationService.cs:86), so the user insert (first save) and the outbox row for UserRegistered (raised in OnUserRegisteredAsync once the id exists, :166-172) commit together. The event is not fire-and-forget after the fact, it is captured by the outbox inside the same transaction (ADR-003); the class remarks spell out the failure the design removes, namely a serialized UserId = 0 payload that the cross-service Conference consumer cannot resolve (:31-41). The external-login path does the same through ExternalLoginAsync (:192, transaction at :199, with the event raised only for a brand-new account, :299-309). The two paths differ in one payload field: a local registration raises the event with EmailVerified: false, because an address typed into a form proves nothing, so Conference logs any speaker whose email matches for an organizer to link by hand instead of auto-linking (:160-163, :169), while the OAuth path carries the provider's own verified-email assertion, which is what decides whether that auto-link may run at all (:181-183, :260-265, :307). Second, the link-by-email gate: linking an external identity to an existing local account on nothing but an email match would be an account-takeover path through any provider that hands out unverified emails, so TryLinkProviderToExistingAccountAsync (AuthenticationService.cs:350) consults IExternalLoginEmailVerifier (MMCA.ADC.Identity.Application/Users/IExternalLoginEmailVerifier.cs:11) and returns Auth.ExternalEmailNotVerified when the answer is no (AuthenticationService.cs:372-381); the check before it refuses outright when the address already resolves to an account linked to a different provider, because the aggregate stores one provider pair and re-linking would strand the original login (:333-343), and the check after it refuses an account that already signs in with a password (Auth.ExternalLinkRequiresLocalSignIn, :361-374), because the verifier proves only the provider side of the address and nothing proves the local row's address was ever confirmed, so only an account with no local credentials is claimable by an email match. Its implementation HttpContextExternalLoginEmailVerifier (MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:17) lives at the API edge because the assertion lives in the short-lived ExternalLogin cookie principal: it re-authenticates that scheme and reads the email_verified claim (:32-35), and an absent claim, absent principal, or non-request context all report unverified. It fails closed, which means GitHub logins (whose OAuth payload carries no such assertion) never auto-link by design; the host maps Google's email_verified and legacy verified_email payload keys onto that one claim (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:211-212) and does the same for Apple's id_token assertion (:213). Identity signs its tokens with RS256 and publishes the public key at /.well-known/jwks.json; peer services validate tokens by fetching that document rather than sharing a secret (Program.cs:33-37, ADR-004, [Rubric §11, Security]). The audience those peers expect is configuration with one named contract: JwtAudience owns the Jwt:Audience key (JwtAudience.cs:17) and a RequireConfigured guard that throws when the value is absent or blank (:25-29), and the Conference, Engagement and Notification hosts each pass their bearer setup through it (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:335, MMCA.ADC.Engagement.Service/Program.cs:181, MMCA.ADC.Notification.Service/Program.cs:171). The value deliberately has no hardcoded fallback: a host that silently used one would boot with an audience nobody configured and answer every request with a 401 that reads like a token problem instead of a wiring problem, so a missing key fails at startup instead (JwtAudience.cs:8-13). Third, one EmailExistsAsync override deserves its own note, because the reason is the opposite of the obvious one: it passes ignoreQueryFilters: true (AuthenticationService.cs:136-137) not to keep an erased address reserved. Erasure always pairs Delete with Anonymize, which rewrites the address to a placeholder, so a real erased email is re-registrable by design (GDPR), and the unique Email index is filtered on [IsDeleted] = 0 anyway, so a soft-deleted row no longer blocks the insert. The filter bypass stays so the pre-insert check reports a live conflict instead of letting a legacy or placeholder row surface as a save-time surprise (:126-136).

The HTTP surface is equally thin. AuthController (MMCA.ADC.Identity.API/Controllers/AuthController.cs:31) extends UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (G12), which supplies the login / register / refresh / revoke actions inherited from AuthControllerBase plus the three self-service account actions (PUT password, PUT preferences, GET preferences, AuthController.cs:18-25); the ADC subclass adds only two overrides and two command factories. RegisterAsync (:58) captures the client IP for registration rate limiting and the user-agent that names the new refresh session (BR-213, :62-66) and carries the per-IP auth-ip fixed window; LoginAsync re-declares the same window as a password-spray guard, because the per-email lockout alone cannot throttle one source spraying one password across many addresses. CreateChangePasswordCommand and CreateChangePreferencesCommand are the only wiring the base needs to dispatch ChangePasswordCommand (ChangePasswordCommand.cs:15) and ChangePreferencesCommand (ChangePreferencesCommand.cs:14) through the G05 decorator pipeline, where both writes declare ICacheInvalidating with a User-typed cache prefix so a stale cached read cannot mask the change (ChangePreferencesCommand.cs:15, :18). The three handlers on that path are all body-less subclasses of a G14 base whose only purpose is that the source reported on every error stays the ADC class name, which clients match on: ChangePasswordHandler (ChangePasswordHandler.cs:24, ADR-032), ChangePreferencesHandler (ChangePreferencesHandler.cs:17), and GetUserPreferencesHandler (GetUserPreferencesHandler.cs:8-13). The password one is body-less but not dependency-less: it hands the base the IRefreshSessionStore, the ILoginProtectionService and a TimeProvider (ChangePasswordHandler.cs:28-30) so a successful change revokes every live session on the account, because otherwise a stolen refresh chain outlives the credential the user just replaced, which is the one remediation a compromised account has (:19-22). OAuthController (OAuthController.cs:21) is a body-less subclass of OAuthControllerBase (G12) that drives the Google/GitHub/Apple challenge, callback, complete, single-use-code-exchange flow with tokens never riding the redirect URL, and with the class-level routing and versioning attributes re-declared locally because they are not reliably inherited (:10-19); it is an ADC-only feature, since MMCA.Store uses local credentials only. UserClaimsController (UserClaimsController.cs:16) reflects the authenticated JWT's claims back to the client (:28-38). UsersController (UsersController.cs:30) hosts the rest: the three avatar endpoints, the organizer user list (:141), and the account delete (:166). Its list endpoint is gated by capability rather than by role name, [HasPermission(IdentityPermissions.UsersRead)] (:139), and the identity:users:read grant (MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:13) is handed to Organizer, and to no other role, through IdentityPermissionGrants.Apply in AddModuleIdentityAPI (MMCA.ADC.Identity.API/DependencyInjection.cs:49, map at IdentityPermissionGrants.cs:36-38, ADR-020). That list itself is served by GetUsersHandler (MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:17) from a GetUsersQuery carrying the filter, sort, and paging values (GetUsersQuery.cs:12); the handler clamps the page size at 500 through PagingMath before touching the database (:29, BR-11) and pushes filtering, COUNT, ordering, OFFSET/FETCH paging, and the UserListDTO projection into SQL (:35-58), so the credential columns are never materialized ([Rubric §12, Performance and Scalability]). Its sort carries an Id tie-break (:109-110) that makes the ORDER BY total: without it, rows sharing a sort key can repeat or vanish across pages, because OFFSET/FETCH has no stable row order to page over.

Password recovery: the anonymous half of the credential lifecycle

PUT /Auth/password only serves a user who can already sign in. The recovery pair that serves one who cannot is a second, anonymous vertical, and it is assembled the same way: two ADC command records over two G14 workflow bases, exposed by a sibling controller. ForgotPasswordCommand (MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12) carries nothing but the address, and the record's own summary states the two properties that follow from that (:6-10): it is anonymous by design, because a caller who has lost the credential has no user identifier to scope the command to, and every outcome is reported as success so the response cannot be used to enumerate registered addresses. ForgotPasswordHandler (ForgotPasswordHandler.cs:21) inherits ForgotPasswordHandlerBase<TUser, TCommand>, which mints the single-use token through IPasswordResetTokenService and mails it through IEmailSender under PasswordResetSettings; ADC overrides exactly one member, the untracked lookup by address (:28-33), which mirrors the one AuthenticationService already uses for login.

The redemption half is ResetPasswordCommand (ResetPasswordCommand.cs:15), which carries the address, the token, and the new password, and which declares ICacheInvalidating with the same User-typed prefix as the authenticated change (:15, :18) for the same reason: the credential the cached aggregate carries has just changed. ResetPasswordHandler (ResetPasswordHandler.cs:24) is a body-less subclass of ResetPasswordHandlerBase<TUser, TCommand> kept only so the reported error source stays ResetPasswordHandler (:13-17); it takes ILoginProtectionService as a constructor dependency (:22) because the base clears the account's lockout after a successful reset, so a user who locked themselves out by guessing can use the new credential immediately. Both actions are exposed by PasswordResetController (PasswordResetController.cs:28), a subclass of PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> that supplies only the two command factories (:36, :39) and inherits the anonymous posture, the auth-ip rate limiting, and the idempotency from the base (:12-18). It is routed to the same Auth prefix as AuthController (:26) and is a sibling controller rather than more actions on that class, because AuthController already occupies the single inheritance chain, and riding the existing /Auth route means the YARP Gateway needs no change (:19-24). The link the email carries is host configuration, not code: the AppHost injects PasswordReset__ResetUrl pointing at the UI's /reset-password page, because the UI port is dynamic under Aspire and the appsettings default would otherwise address a host that is not listening (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:461-466). The storage decision behind the token itself (cache-backed, rather than columns on the user row or a self-contained signed payload) is recorded in ADR-091 ([Rubric §11, Security]).

Email confirmation: proving the address is reachable

ADC asks a new account to prove its address, and deliberately does not gate sign-in on the answer: Authentication:EmailConfirmation:RequireConfirmedEmail is false (MMCA.ADC.Identity.API/Controllers/EmailConfirmationController.cs:13-18). The aggregate carries the one bit, IsEmailConfirmed (User.cs:48), behind IEmailConfirmableUser (:35); it is flipped by the idempotent ConfirmEmail() (:283), set directly when an OAuth provider already asserts a verified address (:271, :304), and reset during erasure (:500). EF maps it as a required column with a false default, so a row written by anything that does not set it stays valid and the migration can backfill the accounts that existed before the flag did (UserConfiguration.cs:107-112).

Both halves are ADC specializations over framework bases. SendEmailConfirmationCommand (.../UseCases/SendEmailConfirmation/SendEmailConfirmationCommand.cs:20) carries one address and serves two entry points: the endpoint dispatches it inline, and registration schedules it as an IInternalCommand named Identity.SendEmailConfirmation.v1 (:19), so a mail outage or a crash right after the 201 defers the send instead of losing it, and its one-string payload round-trips through the JSON the durable queue requires (:12-17, ADR-114). SendEmailConfirmationHandler (SendEmailConfirmationHandler.cs:27) inherits the framework SendEmailConfirmationHandlerBase<TUser, TCommand> and overrides exactly one member, the untracked lookup by address (:37-41), the same one ForgotPasswordHandler and AuthenticationService already use; every outcome is a success so the response cannot be used to enumerate registered addresses, and an SMTP failure is swallowed by the base, leaving the token valid for another attempt (:12-16). The redemption half overrides nothing at all: ConfirmEmailCommand (ConfirmEmailCommand.cs:11) is anonymous by design because the link is followed from an email client with no session (:6-9), and ConfirmEmailHandler (ConfirmEmailHandler.cs:21) lets the base validate and consume the token, load the account the token names, and call the aggregate idempotent ConfirmEmail, so a token that is wrong, expired or already spent is answered as invalid, which is also what an attacker guessing tokens sees (:12-17).

EmailConfirmationController (EmailConfirmationController.cs:34) exposes the pair anonymously on the same Auth prefix as PasswordResetController, and for the same reason: AuthController already occupies the single inheritance chain, and riding the prefix means both endpoints reach the gateway through the existing /Auth route with no gateway change; the framework ships the handler bases but no controller base for this pair, so the two actions are written here (:22-28). The send action always answers 202, whether or not the address holds an account and whether or not it is already confirmed, since any other answer would let a caller enumerate registered addresses (:38-42), and it carries [Idempotent], [AllowAnonymous] and the auth-ip rate-limit window together (:46-52). The emailed link carries the address and the token in the URI fragment, which browsers never send to a server, so the live token stays out of ingress access logs, request telemetry and Referer headers (:64-67).

Administration: accounts, roles, and the capability map

The organizer-facing administration surface is a framework surface with ADC vocabulary plugged into it. UsersAdminController (UsersAdminController.cs:25) subclasses the framework UsersAdminControllerBase<TUserAdminDTO> routed at the literal Admin/Users (:23), so the fixed action templates publish /Admin/Users/paged, /Admin/Users/{userId}, /Admin/Users/{userId}/lock and the rest (:10-14), every action gated on the users:manage capability by the base rather than on a role name (:16-19). Its only own members are two overrides that exist to declare idempotency intent for the architecture suite, both [NonIdempotent] with the reason spelled out: locking is already idempotent in the domain, so a replayed response protects nothing, and caching one would answer a later unlock-then-lock cycle from the cache and report a state the account is not in (:33, :42). AdminRolesController (AdminRolesController.cs:36) is the same shape at Admin/Roles (:34), gated on roles:manage (:16-22), and it supplies no ADC service at all: the host AddStoredPermissionGrants call registers a complete role-administration service over the framework own registry and grant store, and a set edits stored rows only, so nothing reachable there can take away a capability the host compiled in (:23-29).

The ADC half is UserAdministrationService (MMCA.ADC.Identity.Application/Users/Administration/UserAdministrationService.cs:38), the module IUserAdministrationService<UserAdminDTO>: the paged roster (:53), one account (:95), lock and unlock (:111), and the role an account holds (:146). Two decisions in it are worth reading. A lock revokes the live sessions: a lock that only refused the next sign-in would leave the account working for the remaining lifetime of every access token it already holds, and its refresh sessions would keep minting new ones, so the same revocation the framework credential-rotation paths run is replicated here over IRefreshSessionStore (:20-26). One role, not a set: the framework contract takes a role list because an app may hold several, and ADC User stores exactly one, so a request carrying zero or two is a validation failure rather than something to silently truncate (:27-31). Paging goes through PagingMath with a 100-row cap restated rather than referenced, because the Application layer does not see the API package and the controller clamps too, so drift here can only ever make this the tighter of the two (:45-50, :60). UserAdminDTO (UserAdminDTO.cs:23) is the row it projects, a DTO of its own rather than more fields on UserListDTO because the two are reached through different permissions: identity:users:read serves the organizer roster, users:manage serves the administration screens, device metadata stays out of both, and no credential material is ever projected (:8-13). UserListDTO does answer the framework IUserAdminDTO so the shared list component can render it, mapping the lock state off LockedOn explicitly rather than renaming the wire contract (UserListDTO.cs:9-13, :39, :42). On the aggregate the lock itself is a nullable instant, LockedOn (User.cs:127), with IsLocked computed from it rather than stored so the state and the instant it started cannot disagree (:132-141), moved by Lock and Unlock (:384, :389), while the role is changed through the validating ChangeRole (:402).

The capability map is the third piece. IdentityPermissions declares identity:users:read (IdentityPermissions.cs:13) and then deliberately aliases two framework strings rather than coining ADC names, UsersManage (:26) and RolesManage (:41), because the framework admin controller bases gate their actions on those exact capability strings (:19-21, :32-34). Both aliases stay out of the module's All list, which holds only the capabilities ADC itself defines (:49-52, rationale at :43-48), so IdentityPermissionGrants grants All plus the two framework capabilities by name to Organizer and to no other role (IdentityPermissionGrants.cs:32-38, rationale at :25-30), which is the strict-subset property ContentEditor is defined by (:19-24); RolesManage is granted in compiled code and never from a stored row, because it is the only protection on the screen that edits stored rows and a deleted row could otherwise lock every operator out of the surface that would restore it (IdentityPermissions.cs:36-39). The extracted Identity host applies more than its own map: TokenPermissionGrants (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Authorization/TokenPermissionGrants.cs:34) calls every module map (:53-59, wired at Program.cs:265, with the stored half added at :277), because TokenService emits one permission claim per permission the minting host registry grants the account role, and a downstream service (or the Blazor sidebar) reads that claim instead of its own registry: a token minted with only Identity grants would deny in the UI and in the CQRS pipeline what the owning service allows at its endpoint (:14-25). Nothing is restated there, each map is called where its module declares it, and only the contracts assemblies come along, which keeps another module entity configurations out of this service database model (:26-32); grants accumulate and union, so applying a map alongside the module own registration is additive and order-independent (:38-42). [Rubric §11, Security] and ADR-020 are the lenses here: the endpoint decides on a capability, the role is only the key that map is keyed by, and the map itself is editable at runtime without a deploy.

The privacy pair: export and erasure

Two use cases make this module the codebase's clearest [Rubric §30, Compliance / Privacy / Data Governance] story, and both are thin ADC specializations of a G14 base. The erasure workflow lives in DeleteUserHandlerBase<TUser, TCommand>; DeleteUserHandler (MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:29) keeps the class name so the reported error source stays stable for clients, and supplies the ADC-specific pieces (:18-27) over a DeleteUserCommand that carries the target id plus the caller's own id and role (DeleteUserCommand.cs:19). HasDeletePrivilege (:39-40) says the Organizer role bypasses the ownership rule, delegating to UserRole.IsOrganizer so the claim's casing does not matter. OnAfterSoftDeleteAsync (:46) does two things. It raises the cross-service UserDeleted integration event on the aggregate (:59), so the outbox row is written by the very SaveChangesAsync that commits the erasure: Engagement holds a DisplayName snapshot on its leaderboard opt-in, lives in its own process with its own database, and never sees Identity's in-process domain event, so the fact and its announcement must not be able to come apart (:53-58). Its payload is deliberately just the user id and a timestamp, because carrying a name or email would publish onto a persistent broker the very personal data the erasure exists to remove (MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:17-21, record at :26-29, wire name pinned by [EventName("Identity.UserDeleted.v1")] at :25). It then schedules the erasure of the avatar photo, which is personal data too: rather than queueing a best-effort post-commit call, the hook enqueues a durable DeleteAvatarBlobInternalCommand row (:61-71, BR-116a, ADR-114). The hook runs inside the erasure transaction, so the instruction is enrolled and commits with the anonymized aggregate or rolls back with it, and a scheduling failure fails the erasure rather than committing a partial one the caller was told had succeeded (:76-86). That is the whole reason for the change: the best-effort call it replaces lost the instruction whenever storage was unreachable or the host died in the post-commit tail, which for an erasure request is a photo of a real person surviving the deletion the account was promised (.../DeleteAvatarBlob/DeleteAvatarBlobInternalCommand.cs:8-14). Note the ordering detail at :49-51: the blob name is captured before Anonymize clears the URL. The erasure itself lives in User.Anonymize() (User.cs:507), which irreversibly overwrites the personal fields with placeholders in place rather than hard-deleting the record (:380-394). Keeping the row lets cross-context scalar references (bookmarks, notifications) and the audit trail survive; the replacement email embeds the user id (deleted-{Id}@anonymized.invalid, :368) so the unique-email invariant still holds across many erased accounts, and the operation is idempotent (an already-anonymized user short-circuits at :374-378). This is the anonymize-in-place model of ADR-005, backed by the IAnonymizable marker, and it satisfies the PRIVACY.md §5 "delete within 30 days" promise (User.cs:496-505).

The data-subject access request (PRIVACY.md §7) is assembled the same way. ExportUserDataHandler (MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:30) inherits ExportUserDataHandlerBase<TUser, TQuery>, which owns the owner-or-privileged authorization, the account load, and the section fan-out; ADC contributes exactly two things (:9-14) on top of an ExportUserDataQuery shaped like the delete command (ExportUserDataQuery.cs:12-15): HasExportPrivilege, again UserRole.IsOrganizer (:38), and BuildSubjectSnapshotAsync (:41), which projects the account's own portable fields into a UserDataExportSubjectDTO (:48-74). Credentials are deliberately excluded: no password hash, no salt, no refresh material, no provider key (MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportSubjectDTO.cs:3-8). One small correctness detail sits at :67-73: SQL Server hands audit timestamps back as Kind=Unspecified, so the handler re-stamps them UTC, which is the only reason the exported JSON carries the Z marker the DTO documents. The subject snapshot is the Subject of the shared envelope UserDataExportDTO, and beside it travel one UserDataExportSectionDTO per registered IUserDataExportSection. ADC registers two, in document order (MMCA.ADC.Identity.Application/DependencyInjection.cs:53-54): EngagementUserDataExportSection (.../ExportUserData/EngagementUserDataExportSection.cs:19) reads bookmarks, submitted session questions, the points ledger, check-in history, and leaderboard participation through IUserEngagementExportService (:30-32) and shapes them into UserDataExportEngagementSectionDTO (:34-68) over the per-row records UserDataExportBookmarkDTO, UserDataExportSubmittedQuestionDTO, UserDataExportPointsEntryDTO, and UserDataExportCheckInDTO, turning enum values into their readable names because a data subject reads this document (:49-51, :58-60); NotificationUserDataExportSection (.../ExportUserData/NotificationUserDataExportSection.cs:18) does the same for inbox rows through IUserNotificationExportService (:29-31), into UserDataExportNotificationSectionDTO and its UserDataExportNotificationDTO items (:33-44). Both return a UserDataExportSectionResult Complete(...) (:70 and :46), and neither catches transport failures (:12-16 in the Engagement file, :11-15 in the Notification one): that is the point. The base wraps every section, so a peer that stays unreachable after the standard Polly resilience pipeline degrades to Available = false and the export still succeeds, which is [Rubric §29, Resilience] and [Rubric §7, Microservices Readiness] applied to a compliance workflow. The endpoint itself is a four-line subclass: UsersDataExportController (MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:26) extends DataExportControllerBase<TQuery>, which owns the action, its authorization, the PrivacyFeatures.DataExport gate and the file-download response, and it is routed at the literal Users (:24) so the published path stays /Users/{userId}/export even though the base fixes the action template (:19-22).

Avatars: the third mutating slice

The avatar trio is a small but complete example of a file-handling slice ([Rubric §11, Security] at the content boundary, ADR-045). UsersController caps the upload in two places: declaratively via [RequestSizeLimit(MaxAvatarRequestBytes)] (UsersController.cs:81), which is the 2 MB image budget plus 64 KB of headroom for the multipart boundary and part headers so a file at exactly the limit is not rejected by Kestrel with a bare 413 before the friendly error can run (:39, :41-48), and imperatively via an explicit length check that returns an Avatar.InvalidUpload validation error (:92-98, BR-116a). The POST also carries [Idempotent] (:80), because the upload replaces the caller's avatar rather than appending one, so replaying the stored URL for a repeated Idempotency-Key saves a re-encode and keeps a flaky mobile upload from looking like a failure (:72-77). SetUserAvatarHandler (MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:27) handles a SetUserAvatarCommand that carries the bytes as a ReadOnlyMemory<byte> (SetUserAvatarCommand.cs:10) and is built on MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload> (:34). It never trusts the client-declared content type: it sniffs magic bytes through the shared ImageContentSniffer before the aggregate is even loaded, so an unsupported upload gets the format error whether or not the account exists (:52-69), re-encodes to a canonical 256x256 JPEG via IImageProcessor (:29, :103), uploads under a randomized blob name through IFileStorageService (:30, :111-118), and parks the replaced blob's name in the MutationContext (:126) so OnMutatedAsync (:141) can schedule its deletion once the new URL is persisted. That deletion is not best effort: the handler enqueues a DeleteAvatarBlobInternalCommand through the framework IInternalCommandScheduler (:31, :156-160), a durable row the processor retries until storage answers (ADR-114). The upload runs inside the mutation, so it also covers the opposite orphan: when the save that should point the row at the new blob fails or throws, HandleAsync schedules that blob's deletion before passing the original outcome on (:71-89), from a fresh DI scope with no cancellation, because the request scope is the one whose save just failed and a cancelled request must not cancel the cleanup (:175-198). The random suffix means a replacement never reuses the old URL, so stale caches self-resolve (:17-18). The result travels back as a UserAvatarDTO built in BuildResult (:169). RemoveUserAvatarHandler (over RemoveUserAvatarCommand) and GetUserAvatarHandler (over GetUserAvatarQuery) are the siblings on the same resource, and the remove path schedules the same durable deletion (RemoveUserAvatarHandler.cs:20, :74-80). All three avatar paths (replace, remove, erase the account) therefore converge on DeleteAvatarBlobInternalCommandHandler, a subclass of the framework DeleteBlobInternalCommandHandlerBase<TCommand> (DeleteAvatarBlobInternalCommandHandler.cs:17-20) whose only own member names the blob kind for the log lines (:23): the base owns the idempotent delete, treating an already-missing blob as the state the command asks for and completing the row, and returning any other failure so the framework backoff retries it (:10-14). The command record itself implements the framework IDeleteBlobInternalCommand under the wire name Identity.DeleteAvatarBlob.v1 (DeleteAvatarBlobInternalCommand.cs:16-17); DeleteAvatarBlobInternalCommandValidator rejects an empty name before the row is executed, which guards a scheduling bug rather than a caller (DeleteAvatarBlobInternalCommandValidator.cs:12-14, :5-9).

Persistence, seeding, and the disabled stub

ModuleApplicationDbContext (ModuleApplicationDbContext.cs:15) is the abstract, engine-agnostic context declaring the single Users set (:22); the concrete per-engine class (SQLServerDbContext today) inherits it, and the base ApplicationDbContext supplies audit stamping, soft-delete query filters, and outbox / domain-event dispatch via interceptors (:9-13, :20). Identity owns its own ADC_Identity database with its own outbox table, so it never races another service's outbox (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:37, ADR-006). UserConfiguration (MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:12) extends EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, maps the Email value object through the shared EmailValueConverter (:20-24), mirrors the invariant length constants onto the columns, stores the names and the free-text device fields as nvarchar so a non-Latin name is kept as entered rather than corrupted to ? by a varchar code page (:26-27, :65-66), ignores the computed FullName, IsExternalLogin and IsLocked members (:114-117), and pins three indexes that encode business rules as schema ([Rubric §8, Data Architecture]): unique Email (:122), a filtered unique index on LinkedSpeakerId that enforces the 1:1 User-to-Speaker relationship of BR-208 (:124-126), and a filtered unique composite on (LoginProvider, ProviderKey) for external accounts (:128-130). None of them spells the soft-delete clause by hand: the framework's SoftDeleteUniqueIndexConvention appends AND [IsDeleted] = 0 at model finalization, and writing it here would double it (:119-121, ADR-095).

Seeding is gated, not ambient. IdentityModuleSeeder (MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:15) returns immediately unless Seeding:IncludeSampleUsers is set (:28-30, defaulting to false so a production service that sets nothing seeds nothing), and only then runs IdentityModuleDbSeeder (MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:28), a subclass of IdentityModuleDbSeederBase<TUser> that contributes only the three-account list (:33-38), the existence predicate (:41) and ADC's User.Create parameter order (:51); the check-then-insert idiom in the base is what makes the seeder idempotent, and the deliberately weak development credentials are documented in its own remarks (:21-25). Note the base's ShouldSeed is deliberately not overridden, so the configuration gate has exactly one home (:17-19). When the Identity module is disabled in a host, the IdentityModule descriptor registers the DisabledAttendeeQueryService null-object stub through RegisterDisabledStubs (IdentityModule.cs:19-20), so a consumer that only needs the attendee list still composes (DisabledAttendeeQueryService.cs:10-11 returns an empty list).

Crossing the service boundary: gRPC and integration events

Identity talks to its peers two ways, and both live in Shared and Contracts so neither side reaches into the other's domain ([Rubric §7, Microservices Readiness]). Synchronously, the Notification service needs the set of active attendee user ids; it depends on the IAttendeeQueryService interface, implemented in-process by AttendeeQueryService (MMCA.ADC.Identity.Application/Users/AttendeeQueryService.cs:12), a projected read of ids for users in the Attendee role (:16-20). Once Identity runs as its own process, the composition root swaps in AttendeeQueryServiceGrpcAdapter (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/AttendeeQueryServiceGrpcAdapter.cs:14), which implements the same C# interface over a generated client and pins a 5-second per-call deadline (:20) far tighter than the shared 30s-attempt / 90s-total resilience budget so a hung peer fails fast rather than stalling a broadcast notification (:17-19); AttendeesGrpcService (MMCA.ADC.Identity.Service/Grpc/AttendeesGrpcService.cs:19) serves the other end by delegating to the in-process implementation (:30), and the host maps it with RequireAuthorization() at Program.cs:339 (the response enumerates attendee user ids, so internal-only ingress is not enough, :329). The swap itself is the Contracts-layer DependencyInjection.AddIdentityAttendeeClient (MMCA.ADC.Identity.Contracts/DependencyInjection.cs:41), which uses Replace rather than TryAdd so it overwrites both the real service and the disabled stub (:45-47), and which must run after ModuleLoader.DiscoverAndRegister (:33-37). Consumer code never changes, only the registration does (ADR-007, ADR-008). The extracted host runs h2c-only for cross-service gRPC, with an optional HTTP/1.1-only health-probe listener, both configured by one call to KestrelEndpointExtensions.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2) (Program.cs:84, rationale at :70-80, ADR-012), and it primes its own request pipeline at startup: SelfHttpWarmupTask (MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:23), a SelfHttpWarmupTaskBase subclass registered at Program.cs:165, replays the organizer user-list read against its own Kestrel endpoint (:33-36) and holds /health/ready not-ready until it has had its chance, deliberately accepting the expected 401 by turning RequireSuccessStatusCode off (:49) because the JIT cost lives in the traversal, not the response (ADR-025, [Rubric §12, Performance and Scalability]).

Asynchronously, the User-to-Speaker link is kept consistent by events, not by a cross-database foreign key. When a user registers, AuthenticationService raises UserRegistered (MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:36, a BaseIntegrationEvent named Identity.UserRegistered.v1 on the wire, :35) on the aggregate, and the outbox carries it to Conference, whose UserRegisteredHandler runs the speaker email-match auto-link (BR-207) only when the event says the provider verified the address, and otherwise leaves the match for an organizer to link by hand (AuthenticationService.cs:182-185, :301-303). Conference then publishes SpeakerLinkedToUser / SpeakerUnlinkedFromUser back, which SpeakerLinkedToUserHandler (MMCA.ADC.Identity.Application/Speakers/IntegrationEventHandlers/SpeakerLinkedToUserHandler.cs:32) and SpeakerUnlinkedFromUserHandler consume to set or clear User.LinkedSpeakerId (:54), so the speaker_id claim appears on the next token issued (eventual consistency, BR-209). Both extend ScopedIntegrationEventHandlerBase<TIntegrationEvent> (:30), which opens one DI scope per event so the singleton handler can reach the scoped unit of work and wraps the body in the log-and-propagate envelope (:19-23); the handlers themselves are idempotent, returning early when the link already matches (:48-52). Their error policy is worth copying: the base logs and re-throws, and the subclass overrides only the log line (LogHandlerFailure at :78-79). The remarks explain the bug that motivated it (:66-77): swallowing meant one transient database fault lost the BR-209 back-link permanently, because the delivery had already been acked. Letting the exception through hands the decision to the delivery mechanism, which leaves the inbox row unprocessed so MassTransit redelivers and then moves the message to the error queue. The host registers both as broker consumers (Program.cs:298-299, rationale at :279-286). This event-carried link is what lets the bidirectional User-to-Speaker relationship survive the service split ([Rubric §6, CQRS and Event-Driven], ADR-006 / ADR-008).

The UI edge

The Blazor surface is registered as an IdentityUIModule (MMCA.ADC.Identity.UI/IdentityUIModule.cs:18), an IUIModule descriptor that contributes three NavItems as resource keys, "My Profile" in the user section for every signed-in user, "Users" in the admin section for Organizers, and "Roles" in the admin section (:17-33, ADR-027). The third one is gated on the capability rather than on a role name (:26-32), and the comment above it says why: role administration is the one screen whose whole point is that the role-to-capability map is editable, so an entry naming a role would state the thing the screen exists to change (:22-25). The sidebar reads the permission claim the Identity host mints from its registry, which is why that host applies every module's grants. The routes come from the IdentityRoutePaths constants /users, /profile, /roles and /confirm-email (IdentityRoutePaths.cs:8-9, :12, :15) plus two builders, one per account (:22-23) and one per role, where the role name is the key because a role has no surrogate id anywhere in the system (:32-33); the UI-layer DependencyInjection wires the module descriptor, the framework user- and role-administration clients, and the user service (MMCA.ADC.Identity.UI/DependencyInjection.cs:24, :31, :36, :39). The anonymous /confirm-email landing page and its client are no longer Identity types: they are the framework ConfirmEmail page and IEmailConfirmationUIService, the latter registered by the Common UI layer (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:166). The Profile page (MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor.cs:18) lets an authenticated user change their password, manage their avatar, and delete their account. It mirrors the server's 2 MB cap client-side before any upload starts (:28, :135-139), validates the new password inline for length and confirmation match so the form error summary carries the message rather than a server round-trip (:43-52, [Rubric §24, Forms / Validation / UX Safety]), and accepts an image from either a browser file input (:127) or, on MAUI, the camera and gallery through IMediaPickerService (:22, :96, :98). It talks to the API through the IUserUIService abstraction (MMCA.ADC.Identity.UI/Services/IUserUIService.cs:17) implemented by UserService (MMCA.ADC.Identity.UI/Services/UserService.cs:21), an AuthenticatedServiceBase subclass that attaches the bearer token and calls the REST users resource (:24), reads answers back through ProblemDetailsResultReader and HttpResultExecutor so nothing throws for a server answer (:14-19), and which deliberately skips the retry policy on the avatar upload because a picker stream is single-shot and cannot rewind (:130-131, against the RetryPolicy.ExecuteAsync every other call uses, :60, :86, :101, :152). UserList (MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:25) is the Organizer-only management screen, and it is now a thin shell over the framework shared UserAdminList<TUser> component, which renders the roster, the mobile cards and the lock / unlock / change-role actions against the Admin/Users endpoints (:12-13); ADC supplies the assignable roles from the shared vocabulary (:31) and the mapping from the component search-filter key onto its own query, which wins over the Email column filter (:38, :56).

Three administration pages sit beside the profile page, all of them thin shells over framework components. UserDetail (MMCA.ADC.Identity.UI/Pages/Users/UserDetail/UserDetail.razor.cs:23) shows read-only account facts plus the two administrative changes, lock or unlock and the single role the account holds, both going through the users:manage-gated Admin/Users endpoints (:12-15); the assignable roles come from RoleNames least to most privileged (:27-28), and the actions are hidden on the signed-in operator own account, because locking or demoting yourself removes the capability needed to undo it and the API has no notion of "the caller" (:17-21). RoleList (MMCA.ADC.Identity.UI/Pages/Roles/RoleList.razor.cs:27) carries no members at all: the shared RoleAdminList owns the load, the empty state and the inline failure, and the class exists only because IStringLocalizer<RoleList> resolves by type full name, so the page needs a type of its own for its .resx pair to attach to (:12-18). RoleEdit (MMCA.ADC.Identity.UI/Pages/Roles/RoleEdit.razor.cs:16) adds one route parameter, the role name (:19-20), over the shared RoleAdminEdit, which renders the host whole permission catalog grouped by area, ticks and locks what compiled code already grants, and saves only the stored set (:6-14). Both keep their page authorization as a role while the sidebar entry that links there states the capability, and that split is deliberate: the route gate is what IdentityRouteAuthorizationTests pins so an organizer-only surface cannot silently regress to a bare [Authorize], while the endpoints behind the page are gated on the capability by the controller base, which is the check that actually decides (RoleList.razor.cs:19-25). The UI targets WCAG 2.1 AA; the login and register flows are scanned by the shared MMCA.Common.Testing.E2E workflow bases and the profile page has its own axe-core scan in ADC's suite (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/AccessibilityTests.cs:416, with the rationale for not inheriting the Common profile base at :368-369), all of it running in the deploy-gating chromium E2E leg ([Rubric §21, Accessibility], [Rubric §22, Responsive and Cross-Browser]).

End-to-end: one registration

To see the chapter cooperate, follow a new attendee signing up. AuthController receives the register POST, captures the client IP for BR-213 rate limiting and the user-agent for the session about to be opened (AuthController.cs:56-59), and calls RegisterAsync on AuthenticationService, which opens one transaction (AuthenticationService.cs:86-88) and hands off to the shared G08 engine. The request shape was already checked by RegisterRequestValidator (RegisterRequestValidator.cs:12) in the pipeline, so the engine only has to confirm the email is not taken (AuthenticationService.cs:136-137), call User.Create(...) with the Attendee role (:139-146), hash the password through the shared IPasswordHasher (ADR-032), add the aggregate, and save. Only after that first save, when the EF identity id exists, does OnUserRegisteredAsync raise UserRegistered and save again (:166-172); both saves sit inside the one transaction, so the user row and its outbox row commit atomically (ADR-003). One step follows the commit: AuthController schedules a SendEmailConfirmationCommand as a durable internal-command row rather than sending the confirmation inline, and a scheduling failure is logged rather than failing the 201, because the account exists and the user can ask for a link from the confirmation page (AuthController.cs:73-93). The refresh half of the token pair is a row in the shared RefreshSessions store, not a column on the aggregate (:263-268). The first access token returned does not yet carry speaker_id. Because the address was typed into a form, the event says EmailVerified: false, so Conference does not auto-link on the email match; it logs the matching speaker for an organizer to link by hand (AuthenticationService.cs:182-185). Once that link is made, Conference publishes SpeakerLinkedToUser; SpeakerLinkedToUserHandler sets User.LinkedSpeakerId (SpeakerLinkedToUserHandler.cs:54), and the attendee's next token carries the claim (AuthenticationService.cs:171-172). No password left the domain in plaintext, no cross-database foreign key was written, no event was hand-dispatched, and the same code path behaves identically whether Identity runs inside the monolith or as its own service, which is exactly the property the framework groups (G01 through G15) exist to provide. For the why behind each choice, ADR-003 (outbox), ADR-004 (JWKS), ADR-005 (soft-delete versus erasure), ADR-006 / ADR-007 / ADR-008 (database-per-service, gRPC extraction, service topology), ADR-012 (mixed Kestrel endpoint profile), ADR-020 (permission registry), ADR-025 (startup warm-up and readiness), ADR-027 / ADR-028 (culture and theme), ADR-029 (login protection), ADR-032 (password hashing), ADR-036 (external OAuth login), ADR-045 (file storage and avatars), ADR-047 (soft-deleted session revocation), ADR-050 (refresh rotation and reuse detection), ADR-091 (cache-backed password reset), ADR-095 (soft-delete unique indexes), ADR-096 (best-effort side effects), and ADR-097 (multi-device refresh sessions) are the primary references; the email-confirmation and user / role administration surface is recorded in ADR-116, and the durable internal-command queue behind the scheduled sends and blob deletions in ADR-114.

AssemblyReference, ClassReference

MMCA.ADC.Identity.API + MMCA.ADC.Identity.Application · MMCA.ADC.Identity.API / MMCA.ADC.Identity.Application · MMCA.ADC.Identity.API/AssemblyReference.cs:5 · Level 0 · class (static) + class

  • What it is: the assembly-marker pair, shipped once per project, that gives typeof()-based assembly scanning a stable handle on the Identity module's assemblies. Four members in this unit: an AssemblyReference and a ClassReference in the API project and the same pair in the Application project. No behavior, no state beyond the reflection handle.
Type File:Line Notes (what differs)
AssemblyReference (API) MMCA.ADC.Identity.API/AssemblyReference.cs:5 static class exposing Assembly (typeof(AssemblyReference).Assembly, line 7) and AssemblyName (line 8, Assembly.GetName().Name ?? string.Empty)
ClassReference (API) MMCA.ADC.Identity.API/AssemblyReference.cs:11 a one-line empty public class ClassReference { }, a handle for APIs that want a type rather than an Assembly
AssemblyReference (Application) MMCA.ADC.Identity.Application/AssemblyReference.cs:5 byte-for-byte the same shape as the API one, differing only in the enclosing namespace (:3) and therefore in which assembly typeof(...) resolves to
ClassReference (Application) MMCA.ADC.Identity.Application/AssemblyReference.cs:11 same empty class; this is the one the module actually passes as a generic argument
  • Depends on: nothing first-party; only System.Reflection (AssemblyReference.cs:1 in both files).
  • Concept introduced, the assembly-marker pattern. [Rubric §2, Design Patterns] (assesses whether the patterns in use are idiomatic and solve a real problem): instead of hard-coding an assembly-name string, a scanner takes a typeof(...) from a type it knows lives in the target assembly, so renaming the assembly cannot silently break discovery. [Rubric §15, Best Practices & Code Quality]: every layer of every ADC module ships this same pair, so registration and discovery code reads identically in every project; Identity's other three layers carry it too (MMCA.ADC.Identity.Domain/AssemblyReference.cs:5,11 and MMCA.ADC.Identity.Infrastructure/AssemblyReference.cs:5,11).
  • Walkthrough: AssemblyReference.Assembly (:7) is a public static readonly Assembly; AssemblyName (:8) is its short name, falling back to string.Empty when reflection returns null. ClassReference (:11) has no members at all. Both files are eleven lines long and identical apart from their namespace declaration.
  • Why it's built this way: a typeof() handle is refactor-safe where a magic string is not, and a non-static ClassReference is needed because a C# static type is not a legal generic type argument. The scanning helper is generic: ScanModuleApplicationServices<TAssemblyMarker>() immediately reduces to the assembly overload via typeof(TAssemblyMarker).Assembly (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:28-30), so the marker's only job is to name an assembly at compile time.
  • Where it's used: the Application ClassReference is the live one: DependencyInjection for the Application layer passes it to services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC.Identity.Application/DependencyInjection.cs:58), which is what discovers the module's domain event handlers, DTO/request mappers, command/query handlers, and validators by convention rather than by an explicit registration line each. The architecture fitness suite uses the same marker fully qualified to rebuild the pipeline under test (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:51). The API pair has no call site in MMCA.ADC/Source today; it exists so the layer-parallel convention holds across all five layers of the module.
  • Caveats / not-in-source: nothing in these files enforces the convention. Whether an architecture rule requires one pair per project is not visible here, and neither AssemblyReference nor ClassReference of the API project is referenced by name anywhere in the repo's source.

GetUserAvatarQuery

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.GetUserAvatar · MMCA.ADC.Identity.Application/Users/UseCases/GetUserAvatar/GetUserAvatarQuery.cs:5 · Level 0 · record (sealed)

  • What it is: the smallest query in the module, a single-field request for the current user's avatar state (BR-116a).
  • Depends on: the UserIdentifierType alias (ADR-048, see primer §2). Nothing else.
  • Concept introduced, the "identity is stamped, never asked for" query. The CQRS query record itself is taught at IQueryHandler<in TQuery, TResult>; the detail worth naming here is stated by the parameter doc (GetUserAvatarQuery.cs:4): UserId is "stamped by the controller, never client-supplied". [Rubric §11, Security] (assesses whether authorization is structural rather than a check that could be forgotten): the endpoint is me/avatar and the id comes from ICurrentUserService (MMCA.ADC.Identity.API/Controllers/UsersController.cs:55-57), so there is no request shape in which one user can ask for another user's avatar. Contrast GetUsersQuery below, whose every field is client-supplied and therefore clamped and gated.
  • Walkthrough: one line (:5), public sealed record GetUserAvatarQuery(UserIdentifierType UserId);. No validator, because there is no client-supplied field left to validate.
  • Why it's built this way: giving even a one-field read its own named query type keeps every use case discoverable in the same place and lets the CQRS decorator pipeline address it by type like any other (ADR-014).
  • Where it's used: constructed by UsersController's GetAvatarAsync (MMCA.ADC.Identity.API/Controllers/UsersController.cs:59-61), handled by GetUserAvatarHandler.

IdentityErrorResources

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Resources · MMCA.ADC.Identity.API/Resources/IdentityErrorResources.cs:11 · Level 0 · class (sealed)

  • What it is: an empty "resource anchor" type for the Identity module's localized error messages. It has no members; its only job is to be a typeof(...) handle that the localization layer uses to find the co-located .resx files (ADR-027).
  • Depends on: nothing first-party. At runtime its .resx siblings are loaded through IStringLocalizerFactory (ASP.NET Core localization).
  • Concept introduced, edge error-message localization keyed by error Code. [Rubric §27, Internationalization] (assesses whether user-facing strings, error text included, are translated rather than English-only). ADR-027 localizes failures at the API edge: a domain Error's Code (for example "User.Email.Empty") is the resource key, and the shared IErrorLocalizer looks that key up across every registered resource source before the failure is written into the ProblemDetails response. Each module contributes translations additively by registering its own anchor type, so Identity's strings live in IdentityErrorResources.resx and IdentityErrorResources.es.resx rather than in one central framework file that every module would have to edit.
  • Walkthrough: the class body is empty (IdentityErrorResources.cs:11-13); everything worth knowing is in the doc comment (:3-10), which records two design points. Keys are the domain error Code. And runtime-variable messages (those that interpolate a user-supplied value) are deliberately omitted from the .resx so they degrade to their English message with the value intact instead of showing a broken or value-less translation.
  • Why it's built this way: AddErrorResources<TResource>() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:136-141) resolves IStringLocalizerFactory, calls Create(typeof(TResource)), and registers the result as one more ErrorResourceSource singleton (:124-125); the convention "a .resx named after the type, sitting beside it" is what binds the strings, so an empty marker class is exactly enough. The framework registers its own ErrorResources through that very same helper inside AddErrorLocalization() (:107-113, the call at :111), which is what makes the mechanism additive rather than replace-only: each source is a plain AddSingleton, so registrations accumulate into the enumerable the localizer reads instead of overwriting one another.
  • Where it's used: registered at startup by the extracted Identity host, services.AddErrorResources<IdentityErrorResources>() (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:290).

IExternalLoginEmailVerifier

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users · MMCA.ADC.Identity.Application/Users/IExternalLoginEmailVerifier.cs:11 · Level 0 · interface

  • What it is: a one-method port that answers a single question about the OAuth login currently in flight: did the external provider explicitly assert that this email address is verified? It is the gate that decides whether an external identity may be auto-linked to an existing local account.
  • Depends on: nothing first-party. The single method returns Task<bool> and takes no arguments, deliberately: the "current external login" is ambient request state, resolved by the implementation, not passed by the caller.
  • Concept introduced, the account-takeover guard as an explicit port. [Rubric §11, Security] (assesses whether authentication trust decisions are explicit and fail closed) and [Rubric §3, Clean Architecture] (assesses dependencies pointing inward). Linking an external identity to a local account on nothing but an email match is a takeover primitive: any provider that hands out unverified email addresses would let an attacker register the victim's address and inherit the victim's account. The verified-email assertion lives in the short-lived ExternalLogin cookie principal, which is an HTTP concern, so the decision input is declared here as an interface in the Application layer and implemented at the API edge. Application code stays free of HttpContext, and the security rule stays testable with a two-line fake. [Rubric §1, SOLID]: an interface with exactly one method and exactly one reason to change.
  • Walkthrough: Task<bool> IsCurrentExternalLoginEmailVerifiedAsync() (IExternalLoginEmailVerifier.cs:19). The XML comment (:13-18) fixes the semantics precisely: true only when the provider explicitly asserts verification (Google's email_verified claim); providers that assert nothing (GitHub's OAuth flow) yield false. There is no third "unknown" state, unknown is treated as unverified. The type-level comment (:3-10) names the consumer and the threat it is defending against.
  • Why it's built this way: fail-closed by construction. Because the contract collapses "not verified" and "no assertion" into false, adding a new provider cannot silently open the auto-link path; it stays closed until someone deliberately maps a verification claim for it (ADR-036).
  • Where it's used: consumed by AuthenticationService inside the external-login workflow (MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:245-246); implemented by HttpContextExternalLoginEmailVerifier, which re-authenticates the ExternalLogin scheme and parses the email_verified claim (MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:32-35), returning false when there is no HttpContext (:26-30), no principal, or no parseable claim value (:33-35).

IdentityModule

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API · MMCA.ADC.Identity.API/IdentityModule.cs:13 · Level 2 · class (sealed)

  • What it is: the Identity module's entry point, the concrete IModule that the ModuleLoader discovers by reflection and registers. Identity is a leaf in the module dependency graph: it declares no prerequisites (doc comment IdentityModule.cs:9-12).
  • Depends on: IModule; ApplicationSettings; its own DependencyInjection's AddIdentityModule; IAttendeeQueryService and DisabledAttendeeQueryService from the Shared layer; Microsoft.Extensions.Configuration and Microsoft.Extensions.DependencyInjection.
  • Concept introduced, the disabled-module stub. The module contract itself is taught in G14; the Identity-specific lesson is RegisterDisabledStubs. [Rubric §7, Microservices Readiness] (assesses whether modules compose and deploy independently): every ADC host boots the same module assemblies but enables only some of them. A host with Identity disabled still contains consumers that depend on IAttendeeQueryService (Notification needs the attendee id list for a broadcast), so this method registers DisabledAttendeeQueryService as a singleton (IdentityModule.cs:19-20). DI validation succeeds, the consumer degrades gracefully, and in the extracted topology the composition root later replaces that stub with a gRPC-backed adapter (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/DependencyInjection.cs:47, a services.Replace(...) of the scoped AttendeeQueryServiceGrpcAdapter; the comment at :45-46 records why Replace and not TryAdd: the in-process implementation or the stub may already be in the container).
  • Walkthrough: three members, all one-liners. Name => "Identity" (:16) is the topological-sort key and the value the loader logs. RegisterDisabledStubs(IServiceCollection) (:19-20) registers the stub singleton. Register(IServiceCollection, IConfigurationBuilder, ApplicationSettings) (:23-24) delegates straight to services.AddIdentityModule(applicationSettings); note that the IConfigurationBuilder parameter is accepted and unused here, because Identity contributes no configuration sources of its own. No dependency-declaration members are overridden, so the interface defaults apply (a leaf). There is deliberately no seeding here: that is a separate IModuleSeeder, IdentityModuleSeeder.
  • Why it's built this way: the module boundary is what makes each module extractable into its own service host without a rewrite (ADR-007, ADR-008). In the extracted MMCA.ADC.Identity.Service only this module is enabled; other services register the disabled stub and then overwrite it with a gRPC client. Application code never learns which transport it got.
  • Where it's used: discovered and registered in Kahn-topological order by the ModuleLoader at host startup; RegisterDisabledStubs runs in hosts where the Identity module is not enabled, for example MMCA.ADC.Notification.Service, whose composition root then calls AddIdentityAttendeeClient() over the top (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:230, with the rationale at :204).

DependencyInjection

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API · MMCA.ADC.Identity.API/DependencyInjection.cs:17 · Level 4 · class (static)

  • What it is: the API-layer composition root for the Identity module. It exposes AddIdentityModule(...), the single call that registers every layer of the module, plus AddModuleIdentityAPI(), which applies the module's role-to-permission grants and wires the OAuth email-verification gate.
  • Depends on: IServiceCollection and TryAddScoped (Microsoft.Extensions.DependencyInjection); ApplicationSettings; the Application-layer AddModuleIdentityApplication (see DependencyInjection for the Application layer) and the Infrastructure-layer AddModuleIdentityInfrastructure; AddPermissions from MMCA.Common.API.Authorization and IdentityPermissionGrants.Apply from the module's Shared project; IExternalLoginEmailVerifier and HttpContextExternalLoginEmailVerifier.
  • Concept introduced, the layered DI fan-out via extension(IServiceCollection). [Rubric §3, Clean Architecture] (assesses inward-pointing dependencies and a single composition point per module): the API layer is the only layer that can see all the others, so it owns the aggregate registration. The method hangs off IServiceCollection through the C# extension(IServiceCollection services) block (DependencyInjection.cs:19), the workspace idiom for DI registration (see primer §4). [Rubric §15, Best Practices & Code Quality]: the three-call body mirrors the layering, so registration order matches dependency order and there is exactly one place to look when wiring changes.
  • Walkthrough
    • AddIdentityModule(ApplicationSettings) (:27-34) calls AddModuleIdentityApplication(applicationSettings) (:29), AddModuleIdentityInfrastructure() (:30), and AddModuleIdentityAPI() (:31), then returns services for chaining (:33).
    • AddModuleIdentityAPI() (:42-58) does two things. First it calls services.AddPermissions(IdentityPermissionGrants.Apply), handing the whole grant map to the framework's AddPermissions helper rather than building .Grant(...) calls inline; those grants back the module's [HasPermission(...)]-gated endpoints, including the organizer user list (ADR-020). Second it registers the OAuth auto-link gate: AddHttpContextAccessor() plus TryAddScoped<IExternalLoginEmailVerifier, HttpContextExternalLoginEmailVerifier>(). The inline comment explains the placement: the verified-email assertion lives in the external-login cookie principal, so the verifier is an API-edge concern.
    • Controllers are not registered here; ASP.NET Core's controller convention discovers them (doc comment).
  • Why it's built this way: one entry point per module is what IdentityModule calls, so module wiring stays discoverable. The grant map itself was pulled out into IdentityPermissionGrants in the module's Shared project, because the token-minting host applies the same map: a token must carry the permission claims of every module, not only of the module the minting host boots, so the map has to be reachable from outside the API project too. TryAddScoped (rather than AddScoped) leaves the door open for a host to pre-register a different verifier.
  • Where it's used: AddIdentityModule is invoked by IdentityModule's Register (MMCA.ADC.Identity.API/IdentityModule.cs:23-24) during topological module registration by the ModuleLoader.

AttendeeQueryService

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users · MMCA.ADC.Identity.Application/Users/AttendeeQueryService.cs:12 · Level 8 · class (internal sealed)

  • What it is: Identity's in-process implementation of the cross-module IAttendeeQueryService contract. It answers one question, "which user ids hold the Attendee role, leaving out the conference-day load test's synthetic accounts", and it is the only way another module gets that answer without touching the Identity domain.
  • Depends on: IAttendeeQueryService (the Shared-layer contract); IUnitOfWork and IReadRepository<TEntity, TIdentifierType>; User, UserRole and SyntheticAccounts (Domain); the UserIdentifierType alias.
  • Concept introduced, serving data across a module boundary through a Shared-layer contract. [Rubric §7, Microservices Readiness] (assesses whether cross-module needs are met by explicit contracts rather than direct type references) and [Rubric §3, Clean Architecture]. Notification must fan a broadcast out to every attendee, but it must never reference MMCA.ADC.Identity.Domain. The interface therefore lives in MMCA.ADC.Identity.Shared, the implementation here in Application, and Notification sees only the interface. That indirection is exactly what later allows the same call to be satisfied over gRPC with no change at the call site (ADR-007). [Rubric §12, Performance & Scalability]: the query projects to an (Id, Email) pair in the database rather than materializing whole User rows, so the wire and the heap carry two columns per attendee, not the aggregate.
  • Walkthrough (primary-constructor injection of IUnitOfWork, :12; the class is internal sealed, so nothing outside the Application assembly can bind to the concrete type)
    1. Read repository (:17), unitOfWork.GetReadRepository<User, UserIdentifierType>(), the read-only repository facade rather than the mutating one, so the intent is visible in the type.
    2. Projected query (:21-24), GetProjectedAsync(u => new { u.Id, u.Email }, u => u.Role == UserRole.Attendee.Value, cancellationToken: cancellationToken). The first lambda is the SELECT projection (an anonymous Id/Email pair), the second the WHERE predicate. Note the .Value: User stores its role as a plain string (MMCA.ADC.Identity.Domain/Users/User.cs:71) and UserRole is a value object, so the comparison unwraps the value object to the string the column actually holds (MMCA.ADC.Identity.Domain/Users/UserRole.cs:24). The global soft-delete query filter is left in force, so erased accounts are excluded automatically (that is what "active users" in the doc comment at :7-11 means, since no explicit IsDeleted test appears here).
    3. Drop synthetic accounts and shape the result (:26), [.. attendees.Where(a => !SyntheticAccounts.IsSynthetic(a.Email.Value)).Select(a => a.Id)]. The filter runs in memory, not in SQL: the inline comment (:19-20) explains that Email is stored through a value converter, so a domain-suffix test does not translate to SQL, and the attendee list is a few hundred rows. SyntheticAccounts.IsSynthetic (MMCA.ADC.Identity.Domain/Users/SyntheticAccounts.cs:30) is a trimmed, case-insensitive EndsWith of @loadtest.adc.invalid (:23), so an organizer broadcast is neither fanned out to the load test's accounts nor counted as reaching them. The collection-expression spread builds the IReadOnlyList<UserIdentifierType> the contract promises.
  • Why it's built this way: pushing the role predicate and the two-column projection into the database keeps a broadcast cheap, with only the cheap suffix test left in memory, and relying on the global query filter for soft-delete means this service can never accidentally diverge from the rest of the system's definition of "deleted" (ADR-005).
  • Where it's used: registered as the IAttendeeQueryService implementation by the Application-layer DependencyInjection (MMCA.ADC.Identity.Application/DependencyInjection.cs:39); consumed by the Notification module. In the extracted topology the registration is replaced by AttendeeQueryServiceGrpcAdapter, and this class becomes the code behind AttendeesGrpcService on the Identity side.

GetUserAvatarHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.GetUserAvatar · MMCA.ADC.Identity.Application/Users/UseCases/GetUserAvatar/GetUserAvatarHandler.cs:10 · Level 8 · class (sealed)

  • What it is: the read side of the avatar feature (BR-116a). It returns the current user's avatar URL, or null inside a successful result when none is set.
  • Depends on: IQueryHandler<in TQuery, TResult>; GetUserAvatarQuery; IUnitOfWork; User; UserAvatarDTO; Result and Error.
  • Concept introduced, "absent value" is a success, "absent row" is a failure. [Rubric §9, API & Contract Design] (assesses whether responses distinguish states unambiguously). The two outcomes look similar from the outside but mean different things, and the handler keeps them apart: a user with no avatar yields Result.Success(new UserAvatarDTO(user.AvatarUrl)) with a null URL inside (:23, the property is string? at MMCA.ADC.Identity.Domain/Users/User.cs:120), which the controller returns as HTTP 200 with a JSON body; a user id that resolves to no row yields Error.NotFound (:22), which the shared failure mapping turns into a 404. The UI can therefore render the "no photo yet" placeholder without treating it as an error. Everything else here is standard CQRS read shape, taught in G05.
  • Walkthrough (primary constructor :10-11, single method :14-24): resolve the read-only repository and load in one expression, unitOfWork.GetReadRepository<User, UserIdentifierType>().GetByIdAsync(query.UserId, cancellationToken) (:18-19); then a ternary (:21-23) mapping null to Result.Failure<UserAvatarDTO>(Error.NotFound.WithSource(nameof(GetUserAvatarHandler)).WithTarget(nameof(User))) and a found user to Result.Success(new UserAvatarDTO(user.AvatarUrl)). Eleven lines of body, no branching beyond that.
  • Why it's built this way: GetReadRepository states the intent in the type, and the fluent WithSource / WithTarget error builders mean the edge can report what was missing without a bespoke error type per use case (ADR-013). Returning a DTO rather than the raw string leaves room to add avatar metadata later without changing the endpoint's shape (ADR-045).
  • Where it's used: injected into UsersController as IQueryHandler<GetUserAvatarQuery, Result<UserAvatarDTO>> (MMCA.ADC.Identity.API/Controllers/UsersController.cs:35) and invoked by GetAvatarAsync for GET /Users/me/avatar (:51-61); its write-side counterparts are the SetUserAvatarCommand and RemoveUserAvatarCommand handlers injected alongside it (:33-34).

LegalAcceptanceService

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users · MMCA.ADC.Identity.Application/Users/LegalAcceptanceService.cs:21 · Level 8 · class (sealed)

  • What it is: Identity's implementation of the framework's ILegalAcceptanceService: it reads and records the signed-in user's Terms of Service acceptance, and is the service behind the framework's Users/me/legal-acceptance endpoints.
  • Depends on: ILegalAcceptanceService (the contract); IUnitOfWork; TimeProvider; User (AcceptTerms, AcceptedTermsVersion, TermsAcceptedOn); LegalAcceptanceDTO; Result and Error; the UserIdentifierType alias.
  • Concept introduced, a thin application service that leaves the rule in the aggregate. [Rubric §3, Clean Architecture] (assesses whether business rules live in the domain, not the orchestration layer) and [Rubric §30, Compliance / Privacy / Data Governance] (a recorded, versioned consent). The service only loads the user, calls the aggregate and saves; the version validation lives in User.AcceptTerms (MMCA.ADC.Identity.Domain/Users/User.cs:314), which checks the version with UserInvariants.EnsureTermsVersionIsValid before stamping it. The standing shown to the client is derived by the shared LegalAcceptanceDTO.Evaluate (MMCA.Common.Shared/Legal/LegalAcceptanceDTO.cs:42), so IsCurrent is computed the same way on every path: true when no version is configured or the accepted version equals the current one by ordinal comparison.
  • Walkthrough (primary-constructor injection of IUnitOfWork and TimeProvider, :21-23)
    1. GetForCurrentUserAsync (:26-40), a read: GetReadRepository<User, UserIdentifierType>().GetByIdAsync(currentUserId, ...) (:31-34); a missing user returns Error.NotFound with source and target set (:37-38); otherwise it returns LegalAcceptanceDTO.Evaluate(currentVersion, user.AcceptedTermsVersion, user.TermsAcceptedOn) (:39).
    2. AcceptForCurrentUserAsync (:43-67), a write: it loads the user through the mutable GetRepository<User, UserIdentifierType>() (:48-51), returns NotFound when absent (:52-56), then calls user.AcceptTerms(version, timeProvider.GetUtcNow().UtcDateTime) (:58). A failed acceptance (an invalid version) is returned as Result.Failure<LegalAcceptanceDTO>(acceptResult.Errors) before anything is saved (:59-62); on success it calls unitOfWork.SaveChangesAsync (:64) and returns the re-evaluated standing (:66).
  • Why it's built this way: injecting TimeProvider rather than reading DateTime.UtcNow keeps the stamped instant testable. New local sign-ups are stamped at registration by AuthenticationService's CreateUser, while external-login accounts are created unstamped, so this service is the path by which the UI acceptance gate records their acceptance later.
  • Where it's used: registered with TryAddScoped<ILegalAcceptanceService, LegalAcceptanceService>() by the Application-layer DependencyInjection (MMCA.ADC.Identity.Application/DependencyInjection.cs:47), explicitly because the convention scan only finds handlers, mappers and validators; the other usage site is LegalAcceptanceServiceTests. The HTTP surface is LegalAcceptanceController.

AuthenticationService

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users · MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:50 · Level 14 · class (sealed)

  • What it is: ADC's authentication service. The generic login, registration, refresh, and revocation workflow is inherited from AuthenticationServiceBase<TUser>; this subclass supplies the ADC-specific pieces: the Attendee default role, the speaker_id claim, the outbox-atomic UserRegistered integration event, and the entire external OAuth login flow.

  • Depends on: AuthenticationServiceBase<TUser> and IAuthenticationService; IUnitOfWork, IPasswordHasher, ILoginProtectionService, AuthenticationValidators and IAuthSessionIssuer (:56), plus IOptions<EmailConfirmationSettings> (:57) and an optional TimeProvider? timeProvider = null (:58, used only to stamp the terms acceptance instant, falling back to TimeProvider.System), the rest passed straight through to the base constructor (:60-66, with twoFactor: null); IExternalLoginEmailVerifier; User, UserRole, UserRegistered, Email, Result and Error; AuthenticationResponse and RegisterRequest; System.Security.Claims.

  • Concept introduced (1), the template-method base with app-specific hooks. [Rubric §2, Design Patterns] (assesses whether recurring shapes use a named, understood pattern) and [Rubric §15, Best Practices & Code Quality]. The base owns the security-critical sequence: validate first, ADR-029 lockout and registration rate limits, the ADR-004 dual-fetch pattern, and BR-205/206 refresh-session rotation with reuse detection (ADR-050). What varies per application is expressed as hooks, and this class overrides exactly those:

    • FindUntrackedByEmailAsync (:91-95), an untracked read (asTracking: false) used by the login path.
    • ValidateLoginCandidateAsync (:105-106) and ValidateRefreshCandidateAsync (:115-116), added for ADR-116: both delegate to the private CheckNotLocked (:118-124), which refuses Error.Unauthorized("Auth.AccountLocked", ...) when user.IsLocked. The base runs the login check after the password check, so reaching the distinct "locked" message already proves the caller owns the account, which is what makes the message safe to show (it is not an oracle for sweeping addresses). The refresh check is the backstop for a session opened the same instant an admin locks the account, since locking revokes the live sessions.
    • EmailExistsAsync (:136-137), the registration uniqueness probe, run with ignoreQueryFilters: true.
    • CreateUser (:147-168), which fixes ADC's default role as UserRole.Attendee.Value (BR-45) and, when a Terms of Service version is configured (Legal:CurrentTermsVersion, read through the base's CurrentTermsVersion), stamps the new account with it: after User.Create succeeds it calls user.AcceptTerms(termsVersion, (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime) (:163-167; User.AcceptTerms is at MMCA.ADC.Identity.Domain/Users/User.cs:314) and surfaces any failure as Result.Failure<User>. By the time this hook runs the base has already refused a request that did not tick the acceptance box. With no version configured the user is returned unstamped. The external-login path creates accounts outside this method, so those users stay unstamped and are asked by the UI acceptance gate on first sign-in; the later read and accept calls are served by LegalAcceptanceService.
    • CreateAccessToken (:171-172), which appends the speaker_id claim when the account is linked to a speaker; the claim is built by the private SpeakerClaims helper (:405), returning null when LinkedSpeakerId has no value so the claim is simply absent rather than empty (BR-209).
    • OnUserRegisteredAsync (:188-194), the post-save hook, below.
  • Concept introduced (2), bypassing the query filter for a uniqueness probe, and what that actually protects. [Rubric §8, Data Architecture] and [Rubric §30, Compliance / Privacy / Data Governance]. Erasure in ADC always pairs Delete with Anonymize, which rewrites the stored address to a placeholder, so an erased account's real email is re-registrable by design, which is exactly what a right-to-erasure promise requires (ADR-005). And the unique Email index is filtered on [IsDeleted] = 0, because the framework's SoftDeleteUniqueIndexConvention appends that clause, so a soft-deleted row does not block the insert at the database level either. What the ignoreQueryFilters: true bypass buys is diagnosis rather than enforcement: the pre-insert check keeps seeing past the filter so it can report a live conflict as a clean domain error, instead of letting a legacy or placeholder row surface later as a save-time surprise.

  • Concept introduced (3), the outbox-atomic registration event, and what its EmailVerified flag settles. [Rubric §6, CQRS & Event-Driven], [Rubric §8, Data Architecture], [Rubric §29, Resilience & Business Continuity]. User.Id is a database-generated identity column, so at the moment the aggregate is created the id is still 0. The outbox serializes an event's payload at capture time, so raising UserRegistered before the insert would persist UserId = 0, and the cross-service Conference consumer, which has no access to the Identity database to re-match by email, could never resolve it. The fix is visible in two places:

    • RegisterAsync (:81-88) re-implements the interface member with new and wraps the base implementation in UnitOfWork.ExecuteInTransactionAsync(token => base.RegisterAsync(request, ipAddress, userAgent, token), cancellationToken). The interface is re-listed on the class declaration specifically so this override wins for callers holding an IAuthenticationService, since the base method is not virtual.
    • OnUserRegisteredAsync (:188-194) runs after the base's first save, when the identity value exists: it calls user.AddDomainEvent(new UserRegistered(user.Id, user.Email.Value, user.FirstName, user.LastName, user.Role, EmailVerified: false)), saves a second time so the outbox row is captured, and returns the user. Local registration always passes EmailVerified: false, because the address was typed into the form and nothing proves the caller owns it: Conference logs any speaker whose email matches for an organizer to link by hand (BR-209) rather than auto-linking. Both saves sit inside the one transaction opened by RegisterAsync, so a crash before commit rolls back user and event together, and after commit the OutboxProcessor guarantees delivery.
  • Concept introduced (4), the three-way external-login resolution, now split into its own link helper. [Rubric §11, Security]. ExternalLoginAsync (:214-223) is a transaction wrapper around ExternalLoginCoreAsync (:226-339), which resolves the caller into exactly one of three paths:

    1. Known external identity (:235-238), a tracked lookup by LoginProvider and ProviderKey. Found means log in, subject only to the lock check below.
    2. Email matches an existing local account (:269-279). The raw claim is parsed through the value object first, Email.Create(email) (:254), failing closed with Error.Validation("Auth.ExternalEmailInvalid", ...) (:255-261) rather than taking .Value directly: Result<T>.Value is null on failure and does not throw, so an unparseable provider claim would otherwise turn the lookup into "find the users whose email IS NULL". When a match is found, the linking decision itself is now delegated to TryLinkProviderToExistingAccountAsync(existingUser, loginProvider, providerKey) (:271-272), a private helper (:350-400) that holds three refusals, checked in order: a one-link-per-account check, Error.Conflict("Auth.ExternalProviderAlreadyLinked", ...) (:359-365), run before any verifier round trip because the aggregate stores a single (LoginProvider, ProviderKey) pair and linking a second provider would strand the original login (IsExternalLogin is LoginProvider is not null); the provider-side takeover guard, externalLoginEmailVerifier.IsCurrentExternalLoginEmailVerifiedAsync() (:372-373), refusing Error.Unauthorized("Auth.ExternalEmailNotVerified", ...) (:375-381) when the provider did not assert a verified email; and the pre-registration takeover guard (SEC-ADC-01), Error.Conflict("Auth.ExternalLinkRequiresLocalSignIn", ...) (:390-396) whenever existingUser.HasLocalPassword. The verifier proves only the provider side of the address, and nothing proves the local row's address was ever confirmed, so linking an account that already signs in with a password could hand whoever typed that address into the registration form the provider owner's identity, with their own password still in place. Only an account with no local credentials is claimable by an email match, and only then does existingUser.LinkExternalProvider(loginProvider, providerKey) (:398) run. Every refusal message names a recovery path (sign in with the original provider, sign in with the password and link from the profile, or Forgot password), so none is a dead end.
    3. Brand new user (:280-303). Unlike the link path, account creation is not gated on the verified-email assertion: emailVerified is captured from the same IsCurrentExternalLoginEmailVerifiedAsync() call (:286-287) and passed straight into User.CreateExternal(email, firstName, lastName, loginProvider, providerKey, emailVerified) (:293-294) rather than used to refuse. Refusing creation would close sign-up entirely for a provider that asserts nothing (GitHub carries no email_verified claim); instead the flag rides along on two downstream decisions: ADR-116 uses it to decide whether the new account starts already confirmed (a verified assertion) or unconfirmed and sent a confirmation link like a local registration, and it is carried on the UserRegistered event (:326-331) so Conference only auto-links a speaker profile when the address was actually proven.

    Before anything is saved, both existing-account paths (matched by provider key, or linked by email) pass the same ADR-116 lock check as the password and refresh hooks: CheckNotLocked(user, nameof(ExternalLoginAsync)) (:310-317) refuses a locked account before a session is opened, so an administrator lock binds every sign-in path; a brand-new account cannot be locked and skips it. All surviving paths then converge (:319): save; for a new user only, raise UserRegistered with the captured emailVerified flag and save again (:326-331), the same post-identity pattern as local registration; and finally hand off to the base's IssueTokensAsync(user, cancellationToken: cancellationToken) (:338), which opens a refresh session through the shared workflow (hash at rest, per-user cap, rotation chain) rather than stamping a plaintext token on the aggregate. The OAuth callback arrives through the framework's OAuthControllerBase, which does not surface the caller's IP or user-agent, so the session records neither; both fields are informational and nothing validates against them.

  • Why it's built this way: the base class keeps every application on one audited auth workflow, so a fix to lockout or refresh-session reuse detection lands once in MMCA.Common rather than per app; the hooks keep ADC's divergences (role, claim, event, lock check) small and named. Session issuance (access-token minting, refresh-session rotation and the per-user session cap) sits behind the framework's IAuthSessionIssuer, which AddInfrastructure registers, so the token lifetimes and the cap are configuration (the Jwt and RefreshSessions sections) rather than constructor arguments of this service (class doc comment, :44-47); one collaborator stands where the token service, clock, session store and session options used to, leaving the constructor at 8 parameters (:51-58), the last being the optional TimeProvider that only the terms stamp in CreateUser uses. The architecture suite's constructor-dependency ceiling still reads 10 (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/ConstructorDependencyCountTests.cs:28), and its comments still name this class as the high-water mark at 10 (:6, :24-27), so the gate carries headroom this class no longer uses. The UserRegistered integration event, rather than an in-process domain event, is a deliberate divergence from MMCA.Store, because Conference runs in a separate process with its own database and can only learn about a registration asynchronously (ADR-003 outbox, ADR-006 database-per-service). The EmailVerified flag is what lets Conference tell a typed-in address from a provider-proven one without re-implementing the OAuth trust decision on its own side of the boundary. The eventual-consistency cost stays the same either way: the first token issued does not yet carry speaker_id, and the claim appears on the next refresh once Conference has published SpeakerLinkedToUser back.

  • Where it's used: registered as the IAuthenticationService implementation by the Application-layer DependencyInjection (MMCA.ADC.Identity.Application/DependencyInjection.cs:36); driven by AuthController for local credentials and OAuthController for the social paths.

  • Caveats / not-in-source: whether email_verified is actually mapped for a given provider is host configuration, not visible here; the verifier's own doc comment (MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:12-14) records that Google is mapped by a PostConfigure<GoogleOptions> claim action in the service host and that GitHub asserts nothing.


DependencyInjection

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application/DependencyInjection.cs:22 · Level 15 · class (static)

  • What it is: the Application-layer registration for Identity. It explicitly binds six services that convention scanning cannot infer, contributes the module's two data-subject export sections, then runs Scrutor scanning to auto-register every handler, mapper, validator, and domain-event handler in the assembly.
  • Depends on: IServiceCollection, TryAddScoped; ApplicationSettings; IAuthenticationService with AuthenticationService, AuthenticationValidators, ISoftDeletedUserValidator with the framework's generic SoftDeletedUserValidator<TUser> closed over User, IAttendeeQueryService with AttendeeQueryService, and IUserAdministrationService<UserAdminDTO> with UserAdministrationService, and ILegalAcceptanceService with LegalAcceptanceService; the IUserDataExportSection contributors EngagementUserDataExportSection and NotificationUserDataExportSection; the AddUserDataExportSection<TSection> and ScanModuleApplicationServices<TAssemblyMarker> helpers from MMCA.Common.Application; ClassReference.
  • Concept introduced (1), explicit registration for the ambiguous, convention scanning for the rest. [Rubric §2, Design Patterns] and [Rubric §15, Best Practices & Code Quality]. Handlers, mappers, and validators follow a one-interface-one-implementation convention, so Scrutor can find them: services.ScanModuleApplicationServices<ClassReference>() means adding a new use-case slice, GetUserAvatarHandler for instance, needs no DI edit at all. The services that are not convention-discoverable, because their interfaces live in other assemblies, have more than one plausible implementation, or (for IUserAdministrationService<UserAdminDTO>) are neither a handler, a mapper, nor a validator so the scan would never find them, are registered by hand. TryAddScoped rather than AddScoped is the load-bearing detail: a host that has already registered an override keeps it, and the module does not clobber it. (The gRPC swap for IAttendeeQueryService goes further and uses Replace, precisely because it must win over whatever is already there: MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/DependencyInjection.cs:47.)
  • Concept introduced (2), closing a framework generic over the app's own entity. [Rubric §1, SOLID] and [Rubric §11, Security]. ISoftDeletedUserValidator is a framework contract used by the shared SoftDeletedUserMiddleware to reject an access token that outlived its account (ADR-047). Identity satisfies it not with a hand-written class but by closing the framework's generic SoftDeletedUserValidator<TUser> over its own User type. The revocation check therefore has exactly one implementation across the whole workspace, and each app contributes only its entity type.
  • Concept introduced (3), the export section as an additive, ordered contribution. [Rubric §30, Compliance / Privacy / Data Governance] and [Rubric §7, Microservices Readiness]. The data-subject export (PRIVACY.md §7) has to include data Identity does not own, so the framework models each contributor as an IUserDataExportSection and the export handler fans out over the whole IEnumerable<>. Here Identity registers the two peers' sections in order, EngagementUserDataExportSection then NotificationUserDataExportSection, which records two consequences: registration order is the section order in the exported document, and the export handler itself needs no registration because the scan below picks it up as an ordinary IQueryHandler. On the framework side, AddUserDataExportSection<TSection> registers the concrete type with TryAddScoped and appends the interface mapping with TryAddEnumerable, so the same section registered twice is added once; the scoped lifetime is deliberate so a section may take repositories or gRPC clients.
  • Walkthrough: AddModuleIdentityApplication(ApplicationSettings) lives inside an extension(IServiceCollection services) block (:24). Body order (:34-60): _ = applicationSettings; discards the parameter with a comment marking it reserved for future decorator configuration; then IAuthenticationService to AuthenticationService (:36), the AuthenticationValidators parameter object (:37), ISoftDeletedUserValidator to SoftDeletedUserValidator<User> (:38), IAttendeeQueryService to AttendeeQueryService (:39), IUserAdministrationService<UserAdminDTO> to UserAdministrationService (:43), the framework's opt-in Admin/Users endpoint backing (ADR-116), and ILegalAcceptanceService to LegalAcceptanceService (:47), which backs the framework's Users/me/legal-acceptance endpoints (Terms of Service acceptance) and, like the administration service, is neither a handler, a mapper nor a validator so the scan would not find it; then the two export sections (:53-54), the Scrutor scan (:58), and return services (:60). The AuthenticationServiceSettings facade that used to sit between AuthenticationValidators and ISoftDeletedUserValidator was deleted on 2026-09-23: AuthenticationService now takes its two IOptions<> bundles directly and DI resolves them without a module-side registration, since IOptions<T> is satisfied by the options infrastructure itself.
  • Why it's built this way: mixing explicit and convention registration keeps the common case zero-ceremony while retaining precise control over the handful of services that need a specific lifetime or an override point. Registering the cross-boundary implementations (ISoftDeletedUserValidator, IAttendeeQueryService) here is what closes the inversion those contracts set up: the framework and the Notification module declare the need, Identity satisfies it. Removing the AuthenticationServiceSettings registration line here is the DI-side half of the same 2026-09-23 change: the facade added an indirection with no state of its own, and deleting it left the two options resolving through the ordinary IOptions<T> pipeline instead of a module-registered holder.
  • Where it's used: called by the API-layer DependencyInjection's AddIdentityModule (MMCA.ADC.Identity.API/DependencyInjection.cs:28), which IdentityModule calls in turn.
  • Caveats / not-in-source: whether applicationSettings will ever be consumed is Not determinable from source; today it is only discarded (:34), taken solely to keep the signature uniform across modules.

IdentityModuleSeeder

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API · MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:15 · Level 16 · class (sealed)

  • What it is: the Identity module's startup data seeder. It is a thin IModuleSeeder that checks a configuration gate, resolves its dependencies from the host service provider, and delegates the actual inserts (default Organizer and Attendee accounts) to the Infrastructure-level IdentityModuleDbSeeder.
  • Depends on: IModuleSeeder; IConfiguration and IServiceProvider (Microsoft.Extensions); IUnitOfWork; IPasswordHasher; IdentityModuleDbSeeder.
  • Concept introduced, the config-gated seeder bridge. [Rubric §8, Data Architecture] (assesses deterministic, repeatable startup state), [Rubric §11, Security], and [Rubric §3, Clean Architecture] (the API layer orchestrates, Infrastructure persists). Two design points matter here. First, the gate: the seeded accounts carry deliberately weak, well-known credentials (three accounts, admin@adc.com with Admin123! among them, at MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:34-39, with an explicit security notice at :21-25), so seeding is opt-in via Seeding:IncludeSampleUsers (IdentityModuleSeeder.cs:29) and the method returns immediately when it is false (:29-30). GetValue<bool> defaults to false when the key is absent, so a production service that configures nothing seeds no accounts at all, exactly the same shape as the conference sample-data gate (comment :24-27). The DB seeder deliberately does not override its base's ShouldSeed (its own remark at :17-19), so this class stays the single home of the gate. Second, the bridge: this class resolves services from the provider instead of taking them in a constructor, then constructs the DB seeder by hand (:34).
  • Walkthrough (:14-37): ModuleName => "Identity" (:17) identifies the seeder to the loader. SeedAsync(IServiceProvider, CancellationToken) (:20-36) resolves IConfiguration (:22), evaluates the gate (:28-30), then resolves IUnitOfWork (:32) and IPasswordHasher (:33) with GetRequiredService (a missing registration fails loudly at startup rather than silently skipping the seed), constructs new IdentityModuleDbSeeder(unitOfWork, passwordHasher) (:34), and awaits its SeedAsync (:35). IPasswordHasher is required because the seed data holds plaintext passwords that must be hashed before they touch the database (doc comment :9-13, ADR-032).
  • Why it's built this way: IModuleSeeder.SeedAsync runs inside a scope the loader creates after the host is fully built, so constructor-injecting scoped services would tie the seeder's own lifetime to that scope. Resolving at the boundary keeps the object simple and scope-agnostic. Keeping the EF insert logic in the Infrastructure *DbSeeder keeps the API assembly free of persistence detail and leaves the insert logic independently testable.
  • Where it's used: discovered through the IModuleSeeder interface and invoked at host startup after the database initialization strategy has created or migrated the schema.
  • Caveats / not-in-source: which hosts actually set Seeding:IncludeSampleUsers is configuration, not source; the code only records the intent (local AppHost and E2E CI) in its comment at :24-27.

AssemblyReference

MMCA.ADC.Identity.Domain + MMCA.ADC.Identity.Infrastructure · MMCA.ADC.Identity.{Domain,Infrastructure} · Level 0 · class (static) · two layer copies (table below)

  • What it is: the static assembly marker each Identity layer ships so that scanning code can say "this assembly" without a string literal and without naming a real business type. Each copy exposes a handle onto its own assembly (Assembly) plus that assembly's simple name (AssemblyName).
  • Depends on: System.Reflection.Assembly (BCL) only, imported at line 1 of each file. No first-party dependencies, which is why both sit at Level 0.
Type (assembly) File:Line Notes (what differs)
AssemblyReference (Identity.Domain) MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/AssemblyReference.cs:5 Domain-layer copy; namespace MMCA.ADC.Identity.Domain (:3). No call site in ADC today (see Caveats).
AssemblyReference (Identity.Infrastructure) MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/AssemblyReference.cs:5 Infrastructure-layer copy; byte-identical body, namespace MMCA.ADC.Identity.Infrastructure (:3). This is the one the Identity migrations host scans.
  • Concept: the assembly-marker idiom is taught once at AssemblyReference in the module-system chapter; these are the Identity Domain and Infrastructure instances of the same shape, and the API, Application, Shared and UI layers ship their own copies (covered in the sibling parts of this chapter). [Rubric §5, Vertical Slice] assesses convention-driven wiring and feature cohesion: a marker lets a scanner target an assembly through typeof(AssemblyReference).Assembly rather than a brittle string or a concrete type name, so adding an entity configuration or a handler to a slice needs no registration edit. [Rubric §1, SOLID] (Dependency Inversion): registration code binds to a deliberate, behavior-free token instead of typeof(UserConfiguration).Assembly, so renaming or moving a real type never silently breaks the scan.
  • Walkthrough: two public static readonly fields, resolved once at type initialization. Assembly = typeof(AssemblyReference).Assembly (line 7 of each file) is the self-referential handle; AssemblyName = Assembly.GetName().Name ?? string.Empty (line 8) is the simple name, with the ?? string.Empty fallback so the field is never null even if the runtime reports no simple name. No constructor, no methods.
  • Why it's built this way: every module layer ships the identical marker so generic scanning code can be told which assembly to scan while the layer identity travels purely in the type argument. That keeps Clean Architecture layering intact: the scanner never needs a type reference that would create an upward dependency.
  • Where it's used: the Infrastructure copy is what the Identity migrations host hands to AddConfigurationAssembly(...): MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Identity/DesignTimeSQLServerDbContextFactory.cs:55, right after the same factory registers the "Identity" data source (:47-50). That is how UserConfiguration is discovered without the migrations host referencing it directly, and the Conference and Engagement migrations hosts do the identical thing with their own Infrastructure markers (.../MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:43, .../MMCA.ADC.Migrations.SqlServer.Engagement/DesignTimeSQLServerDbContextFactory.cs:45). The general registration machinery lives in G14, Module System and Composition.
  • Caveats / not-in-source: the Domain copy has no call site in MMCA.ADC/Source or MMCA.ADC/Tests today: a repository-wide search for the name returns only its own declaration and the other layers' copies. It exists for structural symmetry across layers. Whether a Domain-assembly scan is intended is Not determinable from source.

ClassReference

MMCA.ADC.Identity.Domain + MMCA.ADC.Identity.Infrastructure · MMCA.ADC.Identity.{Domain,Infrastructure} · Level 0 · class · two layer copies (table below)

  • What it is: the empty, non-static companion to AssemblyReference, declared in the same file. It exists so a scanning API that needs an instantiable generic type argument has one available from this assembly, since a C# static class cannot be used as a generic type argument.
  • Depends on: nothing first-party, and nothing from the BCL beyond object.
Type (assembly) File:Line Notes (what differs)
ClassReference (Identity.Domain) MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/AssemblyReference.cs:11 Empty marker, same file as the Domain AssemblyReference.
ClassReference (Identity.Infrastructure) MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/AssemblyReference.cs:11 Empty marker, same file as the Infrastructure AssemblyReference. Identical body.
  • Concept: cross-reference the marker idiom taught under AssemblyReference and first introduced in G14. The companion is needed because the framework's scan helper constrains its type parameter to a reference type: ScanModuleApplicationServices<TAssemblyMarker>() where TAssemblyMarker : class (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:28-30), a constraint a static class cannot satisfy. Its own doc names the expected argument outright, "a type in the module's Application assembly (typically ClassReference)" (DependencyInjection.ModuleScanning.cs:22). [Rubric §33, Developer Experience] assesses how much ceremony the inner loop demands: one conventional token per layer is the entire registration ritual for a new slice.
  • Walkthrough: public class ClassReference { } (line 11 of each file), no members. Its only meaningful property is the assembly it belongs to, read by a scanner through typeof(ClassReference).Assembly (the assembly-typed overload the generic one delegates to is at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:46).
  • Why it's built this way: keeping the instantiable anchor separate sidesteps the static-class generic-argument restriction while leaving AssemblyReference impossible to instantiate by accident. Each layer declares its own copy so it can be scanned by passing its local token.
  • Where it's used: neither of these two copies is passed as a type argument anywhere in ADC today. The Identity module's generic scan uses the Application-layer copy (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58, services.ScanModuleApplicationServices<ClassReference>(), which the comment above it describes as picking up "domain event handlers, DTO/request mappers, command/query handlers, and validators", :45-46). Two test suites pass that same Application copy explicitly: MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/DecoratorPipelineOrderTests.cs:51 and MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserData/ExportUserDataRegistrationTests.cs:26.
  • Caveats / not-in-source: the Domain and Infrastructure copies are declared and unreferenced; they are present for symmetry. Whether a layer-specific scan is planned for either is Not determinable from source.

AssemblyReference

MMCA.ADC.Identity.{API,Application} · MMCA.ADC.Identity.{API,Application} · MMCA.ADC.Identity.API/AssemblyReference.cs:5 · Level 0 · class (static)

  • What it is: the per-layer assembly marker, one static class holding the layer's own Assembly and its AssemblyName string. It carries no behavior; it exists so reflection-driven code can name an assembly without a magic string. This unit covers the API and Application copies (the Domain, Infrastructure, Shared and UI copies are byte-identical and belong to their own layers).
Type File:Line Notes (what differs)
AssemblyReference (API) MMCA.ADC.Identity.API/AssemblyReference.cs:5 resolves to MMCA.ADC.Identity.API
AssemblyReference (Application) MMCA.ADC.Identity.Application/AssemblyReference.cs:5 resolves to MMCA.ADC.Identity.Application
  • Depends on: System.Reflection only (BCL, imported at AssemblyReference.cs:1). No first-party types.
  • Concept introduced, the assembly marker. The pattern is taught for the framework's own layers in G14; this is its Identity realization. Assembly is initialized from typeof(AssemblyReference).Assembly (AssemblyReference.cs:7), so it always resolves to the assembly that declares the marker: that is why the type is duplicated per layer instead of shared, and why the two copies differ only in their namespace line (:3). [Rubric §15, Best Practices & Code Quality] (assesses idiomatic, low-ceremony conventions): a typeof handle survives a project rename, an Assembly.Load("MMCA.ADC.Identity.Application") string does not.
  • Walkthrough: two public static readonly fields, Assembly (:7) and AssemblyName = Assembly.GetName().Name ?? string.Empty (:8). The null-coalescing guard is there because AssemblyName.Name is declared nullable in the BCL, and the repo compiles with warnings as errors, so the nullable flow has to be closed rather than suppressed.
  • Why it's built this way: static readonly fields are computed once at type initialization, so the reflection cost is paid a single time per process rather than at every scan site.
  • Where it's used: as the stable handle for assembly-scanning code (Scrutor convention registration, EF configuration discovery, architecture fitness tests). The scanning call itself takes the sibling ClassReference as its generic argument.

ClassReference

MMCA.ADC.Identity.{API,Application} · MMCA.ADC.Identity.{API,Application} · MMCA.ADC.Identity.API/AssemblyReference.cs:11 · Level 0 · class

  • What it is: an empty, member-less class that exists purely to be a type argument. Generic scanning APIs of the shape DoSomething<T>() derive the target assembly from typeof(T).Assembly, so each layer ships its own ClassReference to point such a call at itself.
Type File:Line Notes (what differs)
ClassReference (API) MMCA.ADC.Identity.API/AssemblyReference.cs:11 declared but not referenced by the API layer's own registration
ClassReference (Application) MMCA.ADC.Identity.Application/AssemblyReference.cs:11 the T in ScanModuleApplicationServices<ClassReference>()
  • Depends on: nothing. public class ClassReference { }, no base type beyond object, no members.
  • Concept introduced: cross-reference AssemblyReference above. The two solve the same problem from opposite directions: AssemblyReference hands out an Assembly value, ClassReference hands out a type that a generic method can turn into one without the caller ever mentioning System.Reflection.
  • Walkthrough: the whole declaration is one line (AssemblyReference.cs:11), sharing the file with its AssemblyReference sibling. It is deliberately neither static nor sealed: a static class cannot be used as a generic type argument at all, and sealing would buy nothing for a type that is never instantiated.
  • Why it's built this way: ScanModuleApplicationServices<T>() reads better and refactors more safely than passing an Assembly argument, and a dedicated empty type avoids accidentally anchoring the scan to some real class that might later move to another project.
  • Where it's used: the Application copy is the type argument at MMCA.ADC.Identity.Application/DependencyInjection.cs:58, which resolves to ScanModuleApplicationServices<TAssemblyMarker> in MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:28-30, a one-line generic wrapper that forwards typeof(TAssemblyMarker).Assembly to the Assembly-typed overload declared at :46. See DependencyInjection for the Application layer.
  • Caveats / not-in-source: whether the API-layer copy has an active consumer is Not determinable from source within this unit; the API registration (MMCA.ADC.Identity.API/DependencyInjection.cs:47-59) does not reference it.

GetUsersQuery

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.GetUsers · MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersQuery.cs:12 · Level 0 · record (sealed)

  • What it is: the query behind the organizer user-management list (BR-51): four optional filters (email, first name, last name, role) plus paging and sort parameters.
  • Depends on: nothing first-party. Every member is a BCL string? or int.
  • Concept introduced, the filter-plus-page-plus-sort read contract. [Rubric §9, API & Contract Design] (assesses whether list contracts are explicit about paging and ordering rather than returning an unbounded collection): the query carries PageNumber / PageSize and SortColumn / SortDirection as first-class parameters, so a caller can never ask for "everything" implicitly. [Rubric §12, Performance & Scalability]: the documented maximum of 500 items per page (BR-11, GetUsersQuery.cs:9) is a contract statement here and an enforced clamp in GetUsersHandler.
  • Walkthrough: a positional record with eight parameters (:12-20): the four nullable filters Email / FirstName / LastName / Role (:13-16), then PageNumber = 1 (:17), PageSize = 10 (:18), and the nullable SortColumn = null / SortDirection = null (:19-20). No body. The XML doc (:3-11) is the contract of record: 1-based page numbers, a maximum of 500 items per page, a default sort column of CreatedOn and a default direction of desc, all of which the handler actually applies.
  • Why it's built this way: defaulted positional parameters let the common call ("page 1, 10 rows") be argument-free while still supporting the full filter and sort surface, and a sealed record is immutable, so the query can flow through the decorator pipeline with no stage able to mutate it.
  • Where it's used: constructed by the organizer Users endpoint on UsersController (MMCA.ADC.Identity.API/Controllers/UsersController.cs:153), which is [HttpGet] (:138) gated by [HasPermission(IdentityPermissions.UsersRead)] (:139) and range-validates pageNumber and pageSize at the boundary with [Range(1, int.MaxValue)] (:146-147). Handled by GetUsersHandler.

SyntheticAccounts

MMCA.ADC.Identity.Domain · MMCA.ADC.Identity.Domain.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/SyntheticAccounts.cs:20 · Level 0 · class (static)

  • What it is: the rule that recognises the accounts the conference-day load test registers in production (SyntheticAccounts.cs:3-19). They are ordinary Attendee accounts to every request path, but they live under one reserved email domain, EmailDomain = "loadtest.adc.invalid" (:23), and two side effects that would leak outside the test skip them.
  • Depends on: nothing first-party; BCL string and StringComparison only.
  • Concept introduced, a reserved-domain marker instead of a flag column. [Rubric §13, Observability & Operability] assesses whether synthetic traffic can be told apart from real use without polluting it: the test accounts need no extra column or role, because the address itself is the marker, so the real login and bookmark paths are measured unchanged. The domain sits under .invalid (RFC 2606), which can never be deliverable, so no real attendee can be swept up by the rule (:14-17).
  • Walkthrough: IsSynthetic(string? email) (:30-31) is an expression-bodied predicate: true when the address is non-null and, after Trim(), ends with the private EmailSuffix ("@" + EmailDomain, :25) under StringComparison.OrdinalIgnoreCase. Case and surrounding whitespace never defeat it.
  • Why it is built this way: a pure static predicate in the Domain project keeps the rule in one place for both call sites below. The remarks note the k6 script spells the same domain, so the two must change together (:16-17); nothing in C# enforces that.
  • Where it's used: two skips. AuthController returns 201 Created straight after registration for a synthetic address, so no confirmation email is scheduled and the SMTP relay's daily quota and reputation are not spent (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:76-79). AttendeeQueryService filters synthetic addresses out of the attendee id list, so an organizer broadcast never targets them (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AttendeeQueryService.cs:26). Unit-tested by SyntheticAccountsTests under MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Users/.
  • Caveats / not-in-source: the k6 script (Tests/Load/k6/conference-day-attendee-load.js) is named in the doc comment but not read here, so whether its copy of the domain still matches is Not determinable from source within this unit.

UserRole

MMCA.ADC.Identity.Domain · MMCA.ADC.Identity.Domain.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:18 · Level 4 · class (sealed)

  • What it is: the closed set of ADC roles as a value object: Organizer, Attendee, and ContentEditor, with case-insensitive parsing, validation, and equality.
  • Depends on: RoleValue (the shared base), RoleNames (the string constants), Result and Error; externals: System.Collections.Frozen.FrozenDictionary.
  • Concept introduced, the closed-set value object over a persisted string. [Rubric §4, DDD] assesses whether a concept with rules gets a type instead of a bare primitive, and [Rubric §2, Design Patterns] covers the shared-base factoring. The base RoleValue supplies case-insensitive value equality, hashing, and validation once for both apps; this type only fixes the ADC role set, and the class remarks say so (UserRole.cs:12-17). Note what it is not: an enum. The User aggregate stores Role as a string (User.cs:71) so EF maps a plain column and a JWT claim round-trips without conversion, while every place that reasons about roles goes through this type. That combination, primitive at rest and typed in the domain, is the pattern ADR-068 describes.
  • Walkthrough
    • The three static instances (UserRole.cs:21, :23, :30), each built from a RoleNames constant so the domain and the auth layer cannot drift apart. The ContentEditor doc (UserRole.cs:26-30) is worth reading: it defines the role as a strict capability subset of Organizer, able to curate sessions, speakers, and categories but not to change event structure, rooms, feedback questions, run session selection, or read the user list.
    • AllByValue (UserRole.cs:33-34), a FrozenDictionary<string, UserRole> built once by the base's BuildLookup (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleValue.cs:76). Frozen dictionaries trade a slower build for faster reads, which is exactly right for a table populated at type-init and then read on every authorization check ([Rubric §12, Performance & Scalability]).
    • All (UserRole.cs:45) exposes the values; the private constructor (UserRole.cs:36-39) makes the three statics the only instances that can exist.
    • FromString(string role) (UserRole.cs:52-59) is the Result-returning parse: a hit returns Result.Success, a miss returns Error.Invariant with the code User.Role.Invalid. role ?? string.Empty (UserRole.cs:53) means a null argument is a clean validation failure, not a NullReferenceException.
    • IsValid(string role) (UserRole.cs:66) is the boolean form used by UserInvariants.EnsureRoleIsValid, with the same null-to-empty coalesce.
    • IsOrganizer(string? role) (UserRole.cs:76) compares with StringComparison.OrdinalIgnoreCase, and its doc explains the trap it exists to avoid (UserRole.cs:68-75): a raw claim string may carry any casing, and comparing against Organizer.Value directly would compare ordinally, quietly failing on "organizer". This is an authorization-relevant helper, so the subtlety is load-bearing ([Rubric §11, Security]).
    • The equality surface: the two operators (UserRole.cs:78-81) and the three overrides that forward to the base (UserRole.cs:84, :86, :89), plus ToString() returning the canonical string form (UserRole.cs:94).
  • Why it's built this way: a FromString returning Result<UserRole> instead of throwing keeps role parsing on the same error channel as every other validation in the codebase (ADR-013), and keeping the role set in the Domain assembly means the invariant "a user's role is one of these three" is enforced where the aggregate lives, not at the API edge.
  • Where it's used: by UserInvariants.EnsureRoleIsValid (UserInvariants.cs:80); by User's EF constructor default and by CreateExternal, both writing UserRole.Attendee.Value (User.cs:180, :218); by AuthenticationService for the BR-45 attendee default on registration (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:155); by the seeder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:36-38); and by the two ownership-checked handlers whose privilege override is a one-liner, DeleteUserHandler (.../UseCases/DeleteUser/DeleteUserHandler.cs:42-43) and ExportUserDataHandler (.../UseCases/ExportUserData/ExportUserDataHandler.cs:38).
  • Caveats / not-in-source: there is no implicit string conversion on this type, so callers that need the persisted form write .Value explicitly (User.cs:180, AttendeeQueryService.cs:23). The role set here is three values, while the class remarks still describe the spec as defining two (UserRole.cs:12-14); the code is ground truth.

UserInvariants

MMCA.ADC.Identity.Domain · MMCA.ADC.Identity.Domain.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserInvariants.cs:9 · Level 6 · class (static)

  • What it is: the rule book for the User aggregate: the field length constants plus one Ensure... method per invariant, each returning a Result.
  • Depends on: CommonInvariants (the shared primitive checks), Result / Error, UserRole; externals: System.Net.Mail.MailAddress.
  • Concept reinforced, the static invariants class. [Rubric §4, DDD] assesses whether invariants live with the aggregate that owns them, and [Rubric §1, SOLID] covers the single-responsibility split. The framework convention, introduced in group-02, is that an aggregate's factory does not contain its rules: the rules live in a static sibling class as independent, individually testable functions, and the factory composes them with Result.Combine so the caller gets every violation at once instead of the first one. User.Create is the worked example, combining six checks in a single call (User.cs:225-232).
  • Walkthrough
    • Five public constants (UserInvariants.cs:12-24): FirstNameMaxLength and LastNameMaxLength at 100, EmailMaxLength at 100, DeviceFieldMaxLength at 256, and TermsVersionMaxLength at 32 (:24). They are public const for a reason: the Application-layer RegisterRequestValidator reuses them (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:16, :18-19) and the EF configuration sizes its columns from them (UserConfiguration at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:22, :27, :32, and on all seven device columns at :54, :58, :62, :66, :70, :74, :78, and the accepted terms version column at :110), so the domain rule, the wire validation, and the schema cannot disagree.
    • EnsureTermsVersionIsValid (UserInvariants.cs:33-36) validates the Terms of Service version being recorded as accepted: it Result.Combines a non-empty check (User.TermsVersion.Empty) with a max-length check against TermsVersionMaxLength (User.TermsVersion.TooLong), so the value fits its column. User.AcceptTerms is its only caller (User.cs:316).
    • EnsureEmailIsValid (UserInvariants.cs:38-58) is the only sequential (short-circuiting) check: not empty (:25-27), then within length (:29-31), then parseable by MailAddress.TryCreate, returning User.Email.InvalidFormat on the last (:33-40). The order matters because running a format parse on an empty string would report a confusing second error.
    • EnsureFirstNameIsValid / EnsureLastNameIsValid (UserInvariants.cs:60-68) each Result.Combine a non-empty check with a max-length check, so both problems surface together.
    • EnsurePasswordHashIsValid / EnsurePasswordSaltIsValid (UserInvariants.cs:70-74) delegate to CommonInvariants.EnsureBytesAreNotEmpty. They assert presence only: strength is a request-level concern (ChangePasswordRequestValidator) and the hash itself is opaque to the domain.
    • EnsureRoleIsValid (UserInvariants.cs:79-86) defers to UserRole.IsValid and returns User.Role.Invalid on a miss.
    • EnsurePreferredCultureIsValid and EnsurePreferredThemeIsValid (UserInvariants.cs:91-108) forward to the shared CommonInvariants equivalents, passing ADC's own error codes and messages. The allowlist of SupportedCultures (ADR-027) and the light/dark theme set (ADR-028) live in the framework; only the wording is app-specific.
  • Why it's built this way: every method takes a source string that becomes the error's source field, so a failure reports which operation raised it (nameof(Create), nameof(ChangePassword), nameof(UpdatePreferences)). That is why the same invariant can be shared by the create and change paths without losing diagnostic context ([Rubric §13, Observability & Operability]).
  • Where it's used: by User.Create (User.cs:227-231), User.CreateExternal (User.cs:275-276), User.UpdatePreferences (User.cs:375-376), User.ChangePassword (User.cs:405-406), and User.AcceptTerms (User.cs:316); the length constants are reused by RegisterRequestValidator and UserConfiguration.
  • Caveats / not-in-source: EnsureEmailIsValid has no callers in current ADC source. The aggregate validates its address through the Email value object's own Create instead (User.cs:224, :207, :368), so the email invariant here is a second, currently unused expression of the same rule.

User

MMCA.ADC.Identity.Domain · MMCA.ADC.Identity.Domain.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:34 · Level 7 · class (sealed)

  • What it is: the Identity aggregate root. One row per account, holding credentials, role, profile fields, optional MAUI device metadata, external-login identifiers, UI preferences, the avatar URL, email-confirmation and account-lock state, and the scalar link to a Conference Speaker.
  • Depends on: AuditableAggregateRootEntity<TIdentifierType>, IPasswordChangeableUser, IUserPreferences, IErasableUser (which extends IAnonymizable), IEmailConfirmableUser, ILegalAcceptingUser, IAuditedEntity, Email, UserRole, UserInvariants, UserPasswordChanged, UserDeleted, PiiAttribute, IdValueGeneratedAttribute, Result; externals: BCL only.
  • Concept introduced, the interface list as a workflow contract. [Rubric §4, DDD] assesses whether the aggregate is the consistency boundary and enforces its own invariants, and [Rubric §1, SOLID] assesses interface segregation: five narrow capability interfaces (IPasswordChangeableUser, IUserPreferences, IErasableUser, IEmailConfirmableUser, ILegalAcceptingUser) plus the IAuditedEntity marker instead of one fat base (User.cs:34-35). The class remarks (User.cs:18-32) explain something genuinely easy to get wrong. The shared framework workflows are generic over capability interfaces: ChangePasswordHandlerBase<TUser, TCommand> constrains on IPasswordChangeableUser (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:49), the erasure workflow constrains on IErasableUser, and SendEmailConfirmationHandlerBase<TUser, TCommand> constrains on IEmailConfirmableUser (added for ADR-116). ILegalAcceptingUser (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ILegalAcceptingUser.cs:21) is the Terms of Service capability: read-only AcceptedTermsVersion and TermsAcceptedOn plus an idempotent accept operation that User.AcceptTerms implements. Listing IErasableUser on this type directly is load-bearing because Delete here hides the base soft-delete with new (User.cs:485); only re-declaring the interface on User re-maps the interface slot onto this type's own member, which is what keeps the UserDeleted domain event inside the shared erasure path. Remove the interface from the declaration list and the code still compiles while quietly calling the base method instead.
    • IAuditedEntity (User.cs:35) is the one non-behavioural marker in the list, and the remarks justify it (User.cs:26-31): it opts the aggregate into a change history rather than just the last-writer audit fields. [Rubric §30, Compliance, Privacy & Data Governance]: account records are where a support or compliance question actually gets asked (who changed this attendee's email, when was this account raised to Organizer, who anonymized it), and LastModifiedOn/By alone answers only "who touched it last".
    • [IdValueGenerated] (User.cs:33) tells the persistence layer the id is database-generated. That single attribute is the root cause of the UserRegistered two-save dance: the id does not exist until after the insert, which the Create doc states outright (User.cs:202-208).
  • Walkthrough
    • State (User.cs:50-150). IsEmailConfirmed (User.cs:47) records whether the address has been proven reachable, either by redeeming a confirmation link or by an external provider asserting it (ADR-116). Email is an Email value object, not a string, and is marked [Pii] along with FirstName, LastName, and AvatarUrl; the [Pii] marks are what let the privacy tooling find personal columns (ADR-005). PasswordHash and PasswordSalt are byte[] mapped to varbinary(max), inside a scoped CA1819 suppression that names EF mapping as the reason. Role is a string for EF and JWT round-tripping while UserRole owns the rules. LinkedSpeakerId is a nullable SpeakerIdentifierType scalar, the 1:1 bidirectional counterpart of Speaker.LinkedUserId (BR-208). LockedOn (User.cs:127) records when an administrator locked the account, or null while usable; a locked account keeps every row it owns (bookmarks, feedback, points) and can be unlocked, which separates it from the irreversible Anonymize. AcceptedTermsVersion (User.cs:138) and TermsAcceptedOn (User.cs:144) hold the Terms of Service version this user last accepted and when, both null for an account created before the terms existed or through external sign-in (the UI acceptance gate asks on first sign-in). They are consent evidence rather than personal data, so Anonymize keeps them. Every setter is private: state changes only through the methods below.
    • What is deliberately absent: refresh tokens. The class summary states it: sessions are per-device rows in the framework's RefreshSessions table (BR-205/206), hashed at rest, not columns on this aggregate. [Rubric §8, Data Architecture]: a per-device session list cannot be modelled as one nullable token column on the account row, and moving it out is what lets a user revoke one device without touching the others.
    • Computed members: IsExternalLogin => LoginProvider is not null (User.cs:147); IsLocked => LockedOn is not null (User.cs:158), computed from LockedOn rather than stored so the state and the instant it began cannot disagree (the login and refresh paths refuse a locked account, and locking also revokes the account's live refresh sessions, so the lock takes effect within one access-token lifetime rather than at the next sign-in); HasLocalPassword => PasswordHash.Length > 0 (User.cs:168), which the external-login flow reads to decide whether an OAuth identity may be attached by email match alone (an account whose password someone already holds must be claimed by signing in with that password first); and FullName (User.cs:171). All are derived rather than stored so they cannot go stale.
    • Constructors (User.cs:173-197): a private parameterless one for EF that initializes the non-nullable members to safe defaults, including Role = UserRole.Attendee.Value (:163), and a private full one used by the factories.
    • Create (User.cs:216-244): parses the email, then Result.Combines the email result with five UserInvariants checks, returns Result.Failure<User>(result.Errors) on any failure, and otherwise constructs with Id = default.
    • CreateExternal (User.cs:264-292): the OAuth path. It validates email and names only, sets PasswordHash/PasswordSalt to empty arrays and forces UserRole.Attendee.Value, and records LoginProvider/ProviderKey. A new emailVerified parameter (default false, :253) sets IsEmailConfirmed from it (:271): a provider assertion is exactly the proof the confirmation email exists to collect, so an account created from one starts confirmed and is never asked to prove it again; GitHub's OAuth flow asserts nothing, so those accounts start unconfirmed like a local registration. The password invariants are deliberately skipped because an external account has no local password.
    • ConfirmEmail (User.cs:300-304): sets IsEmailConfirmed = true and returns success. Idempotent: confirming an already-confirmed account is a no-op success, which at-least-once delivery of the confirmation link requires.
    • AcceptTerms(string version, DateTime acceptedOn) (User.cs:314-331): validates the version through UserInvariants.EnsureTermsVersionIsValid (:316) and returns the failure unchanged when it is empty or over 32 characters. Idempotent for the version already on record: re-accepting it returns success without touching either field (:322), so the stored instant still says when consent was first given. Otherwise it sets AcceptedTermsVersion and TermsAcceptedOn (:327-328). Read by LegalAcceptanceService (LegalAcceptanceService.cs:39, :66).
    • Behavior: LinkExternalProvider (User.cs:342-347) attaches a provider to an existing local account and also sets IsEmailConfirmed = true (:304), since the link is only ever made after the provider asserted the address as verified (the caller enforces that; it is the account-takeover guard), so the assertion also settles the confirmation question for an account that had never answered it; LinkSpeaker / UnlinkSpeaker (User.cs:354-362) maintain the BR-207/BR-209 scalar cross-context link; UpdateDeviceMetadata sets the seven MAUI fields in one call; SetAvatarUrl records or clears the avatar URL and nothing else, with the doc drawing the boundary explicitly: size, format sniffing, and re-encoding belong to the upload use case, and the domain only stores the resulting public URL.
    • Lock(DateTime utcNow) / Unlock(): Lock is idempotent via LockedOn ??= utcNow, so locking an already-locked account keeps the original instant rather than resetting it, preserving the audit trail's "when the lock began". Unlock sets LockedOn = null and is a no-op on an account that is not locked.
    • ChangeRole(string role) (BR-41, BR-45): validates through UserInvariants.EnsureRoleIsValid, then assigns the role's canonical spelling via UserRole.FromString(role).Value!.Value rather than the caller's casing, since the role column is compared and filtered ordinally elsewhere (the user list's role filter, the JWT role claim). A no-op when the account already holds the role.
    • UpdatePreferences: combines the culture and theme invariants and assigns only after both pass, so a rejected theme cannot leave a half-applied culture.
    • ChangePassword: validates the new hash and salt, assigns, and raises UserPasswordChanged via AddDomainEvent.
    • Delete: the new hiding method. It calls base.Delete() and raises UserDeleted only when that succeeded. Its doc answers the obvious question about outstanding sessions: they are not touched here and do not need to be, because the refresh flow re-fetches the account through the soft-delete query filter, so every session stops working the moment this commits.
    • Anonymize (span ends User.cs:549): the erasure half. It builds the placeholder address deleted-{Id}@anonymized.invalid with CultureInfo.InvariantCulture, and the id embedded in the address is what keeps the unique-email invariant (BR-200) satisfiable across many erased accounts. It is idempotent by construction: if the current email already equals the placeholder there is nothing left to erase and it returns success. Otherwise it overwrites the email, names, credentials, all seven device fields, both external-login fields, and the avatar URL, and now also resets IsEmailConfirmed = false (User.cs:542): the placeholder address is not reachable and was never proven, so the flag cannot keep asserting that it was. AcceptedTermsVersion and TermsAcceptedOn are deliberately kept (User.cs:544-546): they evidence which terms governed the account while it was live and identify no one once the other fields are overwritten.
  • Why it's built this way: Delete and Anonymize are separate operations, and the doc on Anonymize says exactly why: the row survives so cross-context scalar references (bookmarks, notifications) and the audit trail do not break, which is the anonymize-in-place policy of ADR-005. It is also why a re-registration with an erased account's original email succeeds by design. The avatar comment draws the matching line for storage: the domain nulls the URL, and the use case, which knows the blob boundary, deletes the file (ADR-045). IsEmailConfirmed and the lock/role additions exist because ADC opted into the framework's optional Identity completions (ADR-116): ADC sends the confirmation but does not gate sign-in on it (Authentication:EmailConfirmation:RequireConfirmedEmail is false), because an attendee who registers on the conference floor must be able to use the app immediately; the flag is what an organizer reads before trusting an address, and what a later decision to gate would key off without a second migration.
  • Where it's used: persisted by UserConfiguration, projected by UserDTOMapper, driven by AuthenticationService and the Users use cases (ChangePasswordHandler, ChangePreferencesHandler, DeleteUserHandler, ExportUserDataHandler, SetUserAvatarHandler, RemoveUserAvatarHandler, SendEmailConfirmationHandler, and the reset-password path behind PasswordResetController), mutated on the cross-context link by SpeakerLinkedToUserHandler and SpeakerUnlinkedFromUserHandler, and read by GetUsersHandler.
  • Caveats / not-in-source: Anonymize leaves Role, PreferredCulture, PreferredTheme, and LinkedSpeakerId untouched, so an erased account keeps its role, its UI preferences, and any speaker link. Those are treated as non-identifying here; nothing in this file states that judgement, so it is a behavior to notice rather than a documented decision.

GetUsersHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.GetUsers · MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:17 · Level 8 · class (sealed)

  • What it is: the query handler for the organizer user list (BR-51). It filters, counts, sorts, pages and projects User rows into UserListDTO entirely at the database level, then returns a PagedCollectionResult<T> wrapped in a Result.
  • Depends on: IQueryHandler<in TQuery, TResult> (implemented); IUnitOfWork; IQueryableExecutor; IReadRepository<TEntity, TIdentifierType> (obtained via GetReadRepository); PagingMath; User; UserListDTO; Email; PaginationMetadata; GetUsersQuery; BCL System.Linq.Expressions (GetUsersHandler.cs:1).
  • Concept introduced (1), server-side paging, sorting and projection over a no-tracking queryable. [Rubric §12, Performance & Scalability] (assesses whether list endpoints push filter, sort, page and projection down to the database instead of materializing a table and slicing in memory): every stage composes onto IQueryable and executes as SQL, and the read runs against the no-tracking queryable so EF builds no change-tracking graph for rows the caller only reads. [Rubric §30, Compliance / Privacy / Data Governance] (assesses data minimization): the explicit Select projects exactly seven columns, including LockedOn, so password hash and salt, refresh-token state and the device fields are never read out of the database, let alone serialized. The class doc (:12-16) and UserListDTO's own doc (MMCA.ADC.Identity.Shared/Users/UserListDTO.cs:5-8) both state that contract. [Rubric §6, CQRS & Event-Driven]: as a query handler it runs inside the read decorator chain with no transaction, per ADR-014.
  • Concept introduced (2), a total ORDER BY is what makes paging correct. [Rubric §15, Best Practices & Code Quality] (assesses whether known correctness traps are handled rather than left to chance). Sorting by a non-unique key alone leaves the database free to return equal-keyed rows in any order, so with OFFSET/FETCH a row can appear on two consecutive pages or on neither. ApplySorting therefore appends ThenBy/ThenByDescending on the primary key (:109-110) after the chosen key selector, and the comment above it (:106-107) records exactly that reasoning. The tie-break is not cosmetic: it is the difference between a stable pager and one that silently drops users.
  • Concept introduced (3), what a value-converted column can and cannot be filtered by. [Rubric §8, Data Architecture]. User.Email is the Email value object persisted through a value converter, and a provider can compare that column only against another Email, never against a fragment of one: a Contains over a converted property has no SQL translation. The email filter is therefore whole-address equality, not a substring search, and the remark at :71-78 says so explicitly. Two consequences follow and both are handled in code. Matching is case-insensitive for free, because Email.Create normalizes to lowercase on both the stored and the supplied side. And an input that is not a valid address short-circuits to Where(_ => false) (:86), which returns the empty page, the same answer the database would give for an address nobody holds, instead of throwing. The same converted-column rule drives the sort: "EMAIL" orders by u.Email, the value object itself, not u.Email.Value (:130), while the projection uses u.Email.Value (:51) because a Select materializes through the converter rather than translating a member access.
  • Walkthrough
    • Primary-constructor injection of IUnitOfWork and IQueryableExecutor (:17-19), implementing IQueryHandler<GetUsersQuery, Result<PagedCollectionResult<UserListDTO>>> (:19).
    • Clamp the page (:29): PagingMath.Clamp(query.PageNumber, query.PageSize, 500) returns a (Skip, Take) pair. The comment above it (:26-28) is the rationale: this is the BR-11 cap of 500, and the shared helper also floors a non-positive page number or page size and range-checks the offset in 64-bit. In the helper itself, take is Math.Clamp(pageSize, 1, Math.Max(maxPageSize, 1)) (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/PagingMath.cs:37), the page is floored at 1 (:38), the offset is computed as a long (:40), and an offset past int.MaxValue returns (0, 0) (:42), which materializes the empty page that page genuinely holds instead of a negative Skip.
    • Base query (:31-36): unitOfWork.GetReadRepository<User, UserIdentifierType>() yields the read repository (:31), repository.TableNoTracking (:35) is the untracked IQueryable<User>, and ApplyFilters composes the Where clauses (:36).
    • Count, then sort, then page (:39-59): queryableExecutor.CountAsync(baseQuery, cancellationToken) issues a SELECT COUNT against the filtered but unpaged query (:39); ApplySorting adds the ORDER BY (:42); then Skip(skip).Take(take) plus the Select into UserListDTO, now including LockedOn = u.LockedOn (:45-57), become OFFSET/FETCH with an explicit column list, materialized by queryableExecutor.ToListAsync (:59).
    • Wrap (:61-62): new PaginationMetadata(totalCount, take, Math.Max(query.PageNumber, 1)) reports the clamped page size and the floored page number, so the metadata describes the page that was actually served rather than the one that was asked for; the result is returned as Result.Success(new PagedCollectionResult<UserListDTO>(paged, metadata)).
    • ApplyFilters (:79-97): one Where per non-null filter. Email runs through Email.Create and compares whole value objects on success (:81-86); FirstName and LastName use Contains on plain string columns (:89-92); Role compares the string column to the query's string? directly (:93-94), which works because User stores the role as a string (MMCA.ADC.Identity.Domain/Users/User.cs:71), so no value-object unwrapping is needed here.
    • ApplySorting (:99-111): the direction is descending when SortDirection equals "desc" case-insensitively or is null or whitespace (:101-102), which is how the documented desc default is realized. It delegates the key choice to SelectSortKey (:104), then appends the Id tie-break (:106-110).
    • SelectSortKey (:113-136): a private static helper, factored out of ApplySorting so the column-name switch reads as its own unit. Its doc (:113-119) states the intent: every sortable grid column is named here rather than left to the fallback, so a click orders by the column the reader clicked. A switch on sortColumn?.ToUpperInvariant() matches "USERID" or "ID" to u => u.Id (:127), documented by the comment above it (:123-126) as needed because the grid's first column binds to UserListDTO.UserId and sends that name, with "ID" accepted too since the entity property and the REST vocabulary both call it Id; without either mapping the column fell through to the CreatedOn default and a click on it silently reordered by creation time. "EMAIL" / "FIRSTNAME" / "LASTNAME" / "ROLE" / "CREATEDON" map to their own columns (:130-134), and anything else, including null, falls to CreatedOn (:135).
  • Why it's built this way: routing count, sort, page and projection through IQueryableExecutor keeps the work set-based and testable (the executor is the mockable boundary over EF's async extension methods), while the read repository keeps a pure read off the tracking path. The explicit column projection is a type-level guarantee, not a convention: adding a sensitive field to User cannot leak it through this endpoint without someone editing the Select. Delegating the cap to the shared PagingMath rather than open-coding Math.Min means the hostile inputs (page 0, negative size, a page number near int.MaxValue) are handled the same way in every module. Naming every sortable column explicitly in SelectSortKey, rather than only the columns the original grid shipped with, means adding a new sortable grid column is a switch-arm edit instead of a silent fallback to CreatedOn.
  • Where it's used: dispatched by the organizer Users endpoint on UsersController (MMCA.ADC.Identity.API/Controllers/UsersController.cs:153), gated by IdentityPermissions.UsersRead (:139). Its page of UserListDTO feeds the user-management grid in UserList.

DependencyInjection

MMCA.ADC.Identity.Infrastructure · MMCA.ADC.Identity.Infrastructure · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/DependencyInjection.cs:11 · Level 0 · class (static)

  • What it is: the Infrastructure-layer DI entry point for the Identity module. It is a deliberate no-op placeholder today: AddModuleIdentityInfrastructure() returns the IServiceCollection unchanged (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/DependencyInjection.cs:20).
  • Depends on: Microsoft.Extensions.DependencyInjection.IServiceCollection (NuGet, imported at DependencyInjection.cs:1). No first-party types.
  • Concept: the extension(IServiceCollection) registration idiom, taught once in the primer. C# preview extension members let each layer contribute an AddModule{Name}{Layer}() method that reads like a built-in IServiceCollection API. [Rubric §15, Best Practices & Code Quality] assesses uniform, predictable structure: shipping the method even when it registers nothing means the composition root never special-cases Identity, and the empty body is honest about "nothing to register here yet" rather than absent and surprising. [Rubric §3, Clean Architecture] assesses layer discipline: the Infrastructure layer owns its own registration surface and the API layer composes it, rather than the API layer reaching into persistence details itself.
  • Walkthrough: a single extension(IServiceCollection services) block (DependencyInjection.cs:13) exposing public IServiceCollection AddModuleIdentityInfrastructure() => services; (DependencyInjection.cs:20), an expression body that returns the collection for chaining. The class-level XML doc (DependencyInjection.cs:5-10) records why it is empty: Identity has no module-specific infrastructure services beyond the EF configurations and the seeder, and those are discovered by assembly scanning. That claim matches the layer's actual contents: the whole project is five source files, this one, the two markers above, and ModuleApplicationDbContext, IdentityModuleDbSeeder and UserConfiguration.
  • Why it's built this way: keeping the method present even when empty keeps the module-registration pipeline uniform across every module and layer. When Identity later needs a typed infrastructure service (a key store, a read-model query service), it is added here and no caller changes.
  • Where it's used: invoked from the API layer's AddIdentityModule(...) at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:29, between AddModuleIdentityApplication(applicationSettings) (:29) and AddModuleIdentityAPI() (:31). That composite is called in turn by IdentityModule.Register (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModule.cs:24), the IModule contract from G14 (IdentityModule.cs:13).
  • Caveats / not-in-source: the Identity module ships one DependencyInjection class per layer (this Infrastructure one plus the API, Application, Contracts and UI copies covered in sibling parts of this chapter). They all slug to the bare dependencyinjection anchor, which resolves to the first occurrence in the assembled chapter, so cross-references disambiguate by layer in prose.

RoleNames

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Authorization · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/RoleNames.cs:20 · Level 0 · class (static)

  • What it is: the canonical role-name string constants for the ADC Identity module: three roles, Organizer, Attendee, and ContentEditor (RoleNames.cs:20-34), referenced instead of magic strings.
  • Depends on: nothing first-party.
  • Concept, centralized constants over magic strings. [Rubric §11, Security] assesses authorization correctness and [Rubric §15, Best Practices & Code Quality] assesses duplication and single points of change. Roles are stored as plain strings and emitted as claims, so a typo in a literal does not fail to compile: it silently fails to authorize, or worse, silently authorizes nobody while looking correct. Referencing RoleNames.Organizer instead of "Organizer" moves that class of bug to compile time. ContentEditor is documented as a strict subset of the Organizer's capabilities (RoleNames.cs:28-32): it curates the session catalog without rights over event structure, rooms, questions, session selection, or user administration. That subset is precisely the case ADR-020 uses to justify the permission layer, and you can see the payoff in ADC's grants, where Organizer and Admin each receive the module's whole permission set while ContentEditor receives only the content-management slice, one extra line rather than an edit to every attribute (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:43-44,50).
  • Walkthrough: three public const string fields. Organizer = "Organizer" (RoleNames.cs:23) is documented as managing conference master data (:22); Attendee = "Attendee" (:26) is the default for new registrations (:25); ContentEditor = "ContentEditor" (:33) curates the session catalog (sessions, speakers, categories, sponsors, activities) without full organizer rights, and its doc comment (:28-32) spells out exactly which rights it lacks.
  • Why it's built this way: const rather than static readonly so the values can appear in attribute and option initializers that require compile-time constants.
  • Where it's used: the per-app role type (UserRole; MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:21,24,31), each module's permission grants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:41-46, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:58-61, IdentityPermissionGrants.Apply (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/IdentityPermissionGrants.cs:39)), and the ownership-filter bypass role in ADC's Engagement module (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:54).

IdentityPermissions

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Authorization · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:10 · Level 1 · class (static)

  • What it is: the capability-permission catalog for the Identity module: string constants that endpoints demand through [HasPermission(...)] instead of naming roles inline. It holds one ADC-defined capability, reading the user list; the two administration capabilities (users, roles) are the framework's own constants, aliased here and granted separately.
  • Depends on: AdministrationPermissions (the framework's shared administration capability names, aliased here rather than re-coined). BCL IReadOnlyList<string> plus a collection expression.
  • Concept, permission-based authorization over role-based. An endpoint declares the capability it needs (identity:users:read), and the role-to-permission grants are declared once at module registration, so adding a role or re-mapping a capability never touches an endpoint attribute. [Rubric §11, Security] assesses authorization design and least privilege: naming the capability at the endpoint and centralizing the grants makes the authorization surface auditable in one place rather than scattered across [Authorize(Roles = ...)] strings. [Rubric §7, Microservices Readiness] assesses whether a boundary survives extraction: the catalog lives in the module's Shared project, so the in-process host and the standalone Identity service consume the exact same constants with no duplication.
  • Walkthrough: public const string UsersRead = "identity:users:read" (IdentityPermissions.cs:13), documented as "list or read all user accounts (organizer user-management screens)" (:12). public const string UsersManage = AdministrationPermissions.ManageUsers (:26) administers user accounts (lock, unlock, change role); its doc and remarks (:15-25) note it aliases the framework's AdministrationPermissions.ManageUsers because UsersAdminControllerBase gates its actions on that exact capability string, that it stays OUT of All because All is the list of capabilities ADC itself defines, that IdentityPermissionGrants grants it to Organizer by name instead, and that ContentEditor holds none of these, which is the strict-subset property that role is defined by. public const string RolesManage = AdministrationPermissions.ManageRoles (:42) administers roles (list them, edit the stored permission grants attached to each); its doc and remarks (:28-41) add the same "stays out of All, granted by name" note and state it is granted in compiled code and never from a stored row, because it is the only protection on the screen that edits stored rows, so a deleted row could otherwise lock every operator out of the surface that would restore it. public static IReadOnlyList<string> All { get; } (:50) is a collection expression holding only UsersRead (:51-52), documented (:44-49) as every Identity capability ADC itself defines, with UsersManage and RolesManage deliberately excluded because they are the framework's constants and are granted by name in IdentityPermissionGrants instead.
  • Why it's built this way: a namespace:resource:action string convention keeps permissions self-describing and greppable. Splitting All (ADC's own capabilities) from the two aliased framework constants keeps the catalog honest about provenance: a reader scanning All sees only what this module coined, while the grant that actually assigns roles is the single place that decides which framework capabilities Organizer also needs. Aliasing AdministrationPermissions.ManageUsers / .ManageRoles rather than coining Identity-specific names keeps the controller gates and the compiled role grant agreeing on one string per capability.
  • Where it's used: demanded by the user-list endpoint on UsersController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:139, guarding the [HttpGet] action declared at :138), by AdminRolesController and by UsersAdminController; granted in IdentityPermissionGrants.Apply through permissions.Grant(RoleNames.Organizer, [.. IdentityPermissions.All, IdentityPermissions.UsersManage, IdentityPermissions.RolesManage]).

IdentityPermissionGrants

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Authorization · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/IdentityPermissionGrants.cs:17 · Level 3 · class (static)

  • What it is: the module's one role-to-permission grant: it hands every IdentityPermissions capability, plus the two framework administration capabilities the shipped controller bases gate on, to RoleNames.Organizer and to no other role.
  • Depends on: RoleNames; IdentityPermissions; PermissionRegistryBuilder (the framework's grant-accumulation type, from MMCA.Common.Application).
  • Concept: cross-reference IdentityPermissions for the permission-based authorization model this grant populates and for why UsersManage/RolesManage are named here rather than folded into IdentityPermissions.All. [Rubric §11, Security] assesses least privilege: factoring the grant into its own static class, called once from module registration, keeps "who can administer users and roles" answerable by reading one method rather than tracing every [HasPermission] attribute back to a role.
  • Walkthrough: one method, public static void Apply(PermissionRegistryBuilder permissions) (IdentityPermissionGrants.cs:35), guarded by ArgumentNullException.ThrowIfNull(permissions) (:37). The doc comment (:19-24) states the policy in full: every Identity capability goes to Organizer, and content editors deliberately hold none, because their strict-subset property is defined by holding no user or role administration; the remarks (:26-31) add that UsersManage and RolesManage are the framework's own constants (AdministrationPermissions), not ADC's, so All stays the list ADC itself defines and this method grants the two framework capabilities by name instead, "same shape as MMCA.Store". The body is one call: permissions.Grant(RoleNames.Organizer, [.. IdentityPermissions.All, IdentityPermissions.UsersManage, IdentityPermissions.RolesManage]) (:39-41).
  • Why it's built this way: a dedicated Apply method, rather than inline calls at the module's DependencyInjection, gives the grant a single, independently testable entry point that both the in-process API host and the standalone Identity service can call.
  • Where it's used: called from AddModuleIdentityAPI() (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs) and from the Identity service's token permission grants (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Authorization/TokenPermissionGrants.cs), so the in-process host and the standalone service register the identical grant.

RemoveUserAvatarCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.RemoveUserAvatar · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarCommand.cs:8 · Level 0 · record (sealed)

  • What it is: the CQRS command that removes the signed-in user's avatar photo (BR-116a): it clears the stored URL and deletes the blob. It carries only the owning UserId.
  • Depends on: UserIdentifierType, the Identity module's identifier alias (see identifier aliases). The file has no using directives at all.
  • Concept: the avatar slice as a whole is a three-operation feature (get, set, remove) taught in depth at SetUserAvatarHandler; this record is its delete request. The doc comment (RemoveUserAvatarCommand.cs:3-6) records the load-bearing contract: the operation is idempotent, and removing a non-existent avatar succeeds rather than erroring. [Rubric §9, API and Contract Design] assesses predictable, repeat-safe mutation semantics: a delete that is safe to replay lets a client (or a retrying proxy) call it again without special-casing "already gone".
  • Walkthrough: a one-line positional record, RemoveUserAvatarCommand(UserIdentifierType UserId) (RemoveUserAvatarCommand.cs:8). No body. The parameter doc (:7) states that UserId is stamped by the controller from the authenticated principal and is never client-supplied, which is what makes this a self-service-only operation rather than an ownership check the handler has to perform.
  • Why it's built this way: a bare command record keeps the delete inside the command decorator pipeline like every other mutation, and the controller-stamped owner id turns ownership into a property of routing rather than an argument the client controls.
  • Where it's used: constructed by UsersController.RemoveAvatarAsync (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:128), the [HttpDelete("me/avatar")] endpoint (:119) which returns 204 No Content on success (:120, :133). Handled by RemoveUserAvatarHandler, which returns a bare Result with no payload.
  • Caveats / not-in-source: unlike its sibling, this command has no FluentValidation validator, and that absence is deliberate and frozen: it is listed under "identifier-only commands" in CommandValidatorCoverageTests (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/CommandValidatorCoverageTests.cs:56), the fitness gate that otherwise fails a new data-carrying command that ships without one. Its whole payload is a server-supplied id, so there is no field a validator could reject.

RemoveUserAvatarHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.RemoveUserAvatar · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:18 · Level 16 · class (sealed, partial)

  • What it is: the command handler that removes the signed-in user's avatar (BR-116a). It clears the stored URL on the User aggregate first (the user-visible state), then schedules the blob's deletion on the durable internal-command queue (ADR-114). It is idempotent: when no avatar is set it succeeds without writing, without logging and without touching storage.
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> (base class, which supplies ICommandHandler<RemoveUserAvatarCommand, Result>); MutationContext; IUnitOfWork; IInternalCommandScheduler (the framework's durable internal-command queue); DeleteAvatarBlobInternalCommand (the scheduled command this handler enqueues, handled by DeleteAvatarBlobInternalCommandHandler); SetUserAvatarHandler (it calls that class's internal static TryGetBlobName, imported at RemoveUserAvatarHandler.cs:3, which is why it sits one level higher than its sibling); User; Result; Microsoft.Extensions.Logging with [LoggerMessage] source generation.
  • Concept reinforced, the idempotent delete expressed as a workflow short-circuit. The mutation base offers context.SkipSave() for exactly this case: the command is already satisfied, so nothing is written, and the base skips the save, the LogMutated call and the post-commit hook while still answering success (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutationContext.cs:44-49 and the check at MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:308-311). [Rubric §9, API and Contract Design]: the operation is safe to repeat, and the no-op path is an explicit success rather than an error (see RemoveUserAvatarCommand). [Rubric §12, Performance and Scalability]: the already-satisfied path costs one read and nothing else, no transaction and no storage round trip. [Rubric §13, Observability and Operability]: the removal is recorded through a source-generated [LoggerMessage] method (:84-85), so the class is partial, and the skip path deliberately logs nothing, so a repeated delete does not inflate the "avatar removed" signal.
  • Concept introduced, the blob delete is now a durable internal command, not a best-effort inline call. Previously this handler called IFileStorageService.DeleteAsync directly after the commit; it now schedules a DeleteAvatarBlobInternalCommand through IInternalCommandScheduler instead (:74-76). [Rubric §6, CQRS & Event-Driven] and [Rubric §13, Observability and Operability]: ADR-114's internal-command table gives the delete retry semantics a bare try/await cannot, a transient storage outage defers the cleanup instead of losing it silently. The scheduling call can still fail (the row itself might not persist), and that failure is not escalated to the caller: it is logged at Warning through LogAvatarBlobScheduleFailed (:78-81, message declared at :87-88), whose text states the consequence in the log itself: the blob "is orphaned in storage and must be removed by hand."
  • Walkthrough
    • Primary-constructor injection of IUnitOfWork, an IInternalCommandScheduler and an ILogger<RemoveUserAvatarHandler> (RemoveUserAvatarHandler.cs:18-21), with the unit of work forwarded to MutateEntityHandlerBase<RemoveUserAvatarCommand, User, UserIdentifierType> (:22). Note the base without a payload type parameter: this command answers with a bare Result. One constant, private const string BlobNameKey = "Avatar.BlobName" (:25), the context key the orphaned blob's name travels under.
    • EntityId (:28) returns command.UserId, so the base loads that aggregate and answers Error.NotFound itself when the account does not exist.
    • The mutation (:31-50): it derives the blob name with SetUserAvatarHandler.TryGetBlobName(entity.AvatarUrl) (:37). When that is null (no avatar stored, or a URL with no usable final segment) it calls context.SkipSave() and returns Result.Success() (:42-43), with the comment at :40-41 naming the intent: "an already-satisfied command, not a refused one. No save, no log, no storage call." Otherwise it stashes the name under BlobNameKey (:46) and calls entity.SetAvatarUrl(null) (:47, domain setter at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:394).
    • LogMutated (:53-54) fires the source-generated LogAvatarRemoved at Information level with the message "User {UserId} avatar removed" (:84-85).
    • OnMutatedAsync (:62-82), post-commit: reads context.GetOrDefault<string>(BlobNameKey) (:68) and returns immediately when absent (:69-72). Otherwise it schedules new DeleteAvatarBlobInternalCommand(blobName) through internalCommandScheduler.ScheduleAsync (:74-76), and logs LogAvatarBlobScheduleFailed when scheduling itself fails (:78-81). The doc above the method (:56-60) states the ordering rationale: the row is already committed without the URL, and the blob delete becomes an internal-command row the framework retries, so a storage outage defers it instead of leaving an orphan nothing knows about.
  • Why it's built this way: clearing the URL before scheduling the blob delete makes the persisted "no avatar" state authoritative, so a later storage failure leaves an orphaned file rather than a dangling reference to a blob that no longer exists. That ordering is the same ADR-096 discipline as SetUserAvatarHandler, and reusing that handler's TryGetBlobName keeps the URL-to-blob-name parsing in exactly one place, so the two operations can never disagree about which blob a stored URL names. Routing the delete itself through the internal-command queue (ADR-114) rather than an inline await fileStorage.DeleteAsync trades an immediate best-effort attempt for a durable, retried one.
  • Where it's used: dispatched by UsersController.RemoveAvatarAsync (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:127-129, injected as ICommandHandler<RemoveUserAvatarCommand, Result> at :34), whose 204 No Content response (:133) tells the client the avatar is gone (or was already absent). Unit-tested by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandlerTests.cs.
  • Caveats / not-in-source: the internal-command row's own retry and eventual-failure handling live in the internal-command processing pipeline, not in this handler; whether a command that keeps failing is ever surfaced anywhere beyond the Warning log is Not determinable from source within this unit.

UserDataExportBookmarkDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportBookmarkDTO.cs:7 · Level 0 · record (sealed)

  • What it is: one session-bookmark row inside the Engagement section of a data-subject export: which session the user bookmarked and when.
  • Depends on: the SessionIdentifierType alias; BCL (DateTime).
  • Concept introduced, the export row DTO (ids and dates only). [Rubric §30, Compliance, Privacy and Data Governance] (assesses whether a data-subject access or portability request returns the subject's own data, and only that). The doc comment (UserDataExportBookmarkDTO.cs:3-6) ties the shape directly to PRIVACY.md §7. Note what is not here: no session title, no speaker, no other user's activity. The export carries the personal fact ("you bookmarked session X at time T") rather than a denormalized copy of another context's catalog, which keeps the Identity service from becoming an accidental read model of Conference data.
  • Walkthrough: two required init members, SessionId (UserDataExportBookmarkDTO.cs:10) and CreatedOn, documented as UTC (:13). required means the producing section cannot forget a field, and init makes the row immutable once produced (the required/init immutability convention from the primer).
  • Where it's used: built by EngagementUserDataExportSection from the Engagement peer's response, in a [.. export.Bookmarks.Select(...)] spread (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/EngagementUserDataExportSection.cs:36-40), and carried inside UserDataExportEngagementSectionDTO (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportEngagementSectionDTO.cs:14). The source aggregate on the far side is UserSessionBookmark.

UserDataExportCheckInDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportCheckInDTO.cs:8 · Level 0 · record (sealed)

  • What it is: one attendance row in the Engagement section of a data-subject export: what the attendee was checked in to (a session, a sponsor booth, or the event itself) and when.
  • Depends on: nothing first-party (the ids travel as plain int); BCL (DateTimeOffset).
  • Concept: the same export-row shape UserDataExportBookmarkDTO introduces, with one policy worth reading carefully. The doc comment (UserDataExportCheckInDTO.cs:3-7) records that check-ins are reported independently of the points ledger, "since a check-in that earned no points still leaves an attendance record". [Rubric §30, Compliance, Privacy and Data Governance] (assesses completeness of a subject access response): had the export derived attendance from UserDataExportPointsEntryDTO rows, a repeat scan or a scan outside the awarding window would be personal data the system holds and the export never mentions. Two independent lists is the honest answer.
  • Walkthrough: five members (UserDataExportCheckInDTO.cs:11-23). Scope (:11) is a required string, documented as the readable name of what the check-in attests to (for example "Session"); EventId (:14) is required and documented as always set for every scope, which is what makes the row self-locating. SessionId (:17) and SponsorId (:20) are nullable ints, each set only for its own scope, so the row is a discriminated shape expressed with nullability rather than a type hierarchy. CheckedInOn (:23) is a required DateTimeOffset: unlike the sibling rows, which carry DateTime and depend on the producer to stamp UTC, this one carries its offset on the wire.
  • Why it's built this way: Scope is a string and not the CheckInScope enum, and the mapping site says why in a comment: "the export document is read by the data subject, so the scope travels as its readable name rather than a number" (EngagementUserDataExportSection.cs:58-60). A numeric enum value in a portable document is meaningless to the person it was produced for, and it silently re-numbers if the enum is ever reordered. The check-in and points data itself arrives via ADR-072.
  • Where it's used: projected by EngagementUserDataExportSection from UserEngagementCheckInExportDTO rows (EngagementUserDataExportSection.cs:56-65), into the CheckIns list of UserDataExportEngagementSectionDTO (UserDataExportEngagementSectionDTO.cs:26).

UserDataExportNotificationDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportNotificationDTO.cs:8 · Level 0 · record (sealed)

  • What it is: one notification-inbox row in the Notifications section of a data-subject export: the notification id, its title, the sent/read timestamps, and the scope the notification was sent under.
  • Depends on: the UserNotificationIdentifierType alias; BCL (DateTime).
  • Concept: the same export-row shape UserDataExportBookmarkDTO introduces ([Rubric §30, Compliance, Privacy and Data Governance]), with one extra nuance: it does include the Title text, because a notification's title is content that was addressed to this user, so it is part of their personal data rather than someone else's.
  • Walkthrough: six members (UserDataExportNotificationDTO.cs:11-30). NotificationId (:11), Title (:14), and SentOn (:17, documented UTC) are required init. The pair that describes read state is not: IsRead is a plain bool (:20) that defaults to false, and ReadOn is a nullable DateTime (:23) documented as null while unread. An unread row therefore simply carries the defaults, with no ceremony at the construction site. The last member, ScopeKey (:30), is a nullable string carrying the scope the notification was sent under (the doc gives event:1 as the example, :26-29), null for an unscoped notification; its doc states the reason it is exported at all, so the data subject can tell which conference a notification belonged to. That is a small but instructive privacy call: an inbox row without its scope is ambiguous to the reader of a multi-event export, and an ambiguous export is an incomplete one.
  • Where it's used: projected by NotificationUserDataExportSection from the Notification peer's rows (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/NotificationUserDataExportSection.cs:35-43, with ScopeKey copied straight through at :42) into UserDataExportNotificationSectionDTO; the underlying entity is UserNotification.

UserDataExportPointsEntryDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportPointsEntryDTO.cs:7 · Level 0 · record (sealed)

  • What it is: one awarded-points row in the Engagement section of a data-subject export: what the attendee did, how many points it was worth, what it was scoped to, and when.
  • Depends on: nothing first-party; BCL (DateTime).
  • Concept: the same export-row shape as UserDataExportBookmarkDTO, applied to a gamification ledger. [Rubric §30, Compliance, Privacy and Data Governance]: a points ledger is derived data, not something the subject typed in, and it is still personal data the operator holds about them, so it is exported. The Points doc (UserDataExportPointsEntryDTO.cs:12) is precise about which number is reported: "as configured at award time", not recomputed from today's configuration, so the export reproduces what the ledger actually recorded rather than what the current rules would say.
  • Walkthrough: four required init members (UserDataExportPointsEntryDTO.cs:10-19). ActivityType (:10) is a string carrying the readable activity name (for example "SessionCheckIn"), Points (:13) the awarded amount, SubjectKey (:16) the subject the award was scoped to (documented as, for example, the session key), and CreatedOn (:19) the UTC timestamp.
  • Why it's built this way: same reason as UserDataExportCheckInDTO's Scope. The producing section maps p.ActivityType.ToString() off the PointsActivityType enum with the rationale in a comment: the document is read by the data subject, so the activity travels as its readable name "(the same choice the Role field makes) rather than a number" (EngagementUserDataExportSection.cs:49-51).
  • Where it's used: projected by EngagementUserDataExportSection from UserEngagementPointsEntryExportDTO rows (EngagementUserDataExportSection.cs:47-55) into the PointsEntries list of UserDataExportEngagementSectionDTO (UserDataExportEngagementSectionDTO.cs:20); the owning aggregate on the far side is PointsEntry.

UserDataExportPollVoteDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportPollVoteDTO.cs:7 · Level 0 · record (sealed)

  • What it is: one live-poll vote row in the Engagement section of a data-subject export: which poll the user voted in, which option they picked, and when.
  • Depends on: the LivePollIdentifierType and LivePollOptionIdentifierType aliases; BCL (DateTime).
  • Concept: the same export-row shape UserDataExportBookmarkDTO introduces ([Rubric §30, Compliance, Privacy and Data Governance]): ids and a date only. The row records the personal fact ("you chose option O in poll P at time T") and leaves the poll text and the tally on the Engagement side, so the export does not copy other people's data or the catalog.
  • Walkthrough: three required init members: LivePollId (UserDataExportPollVoteDTO.cs:10), OptionId (:13), and CreatedOn (:16, documented UTC).
  • Where it's used: projected by EngagementUserDataExportSection from the peer's PollVotes rows (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/EngagementUserDataExportSection.cs:66-71) into the PollVotes list of UserDataExportEngagementSectionDTO (UserDataExportEngagementSectionDTO.cs:29).

UserDataExportQuestionUpvoteDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportQuestionUpvoteDTO.cs:7 · Level 0 · record (sealed)

  • What it is: one question-upvote row in the Engagement section of a data-subject export: which session question the user upvoted and when.
  • Depends on: the SessionQuestionIdentifierType alias; BCL (DateTime).
  • Concept: the same export-row shape as UserDataExportBookmarkDTO ([Rubric §30, Compliance, Privacy and Data Governance]). It is the upvote counterpart of UserDataExportSubmittedQuestionDTO: questions the user wrote and questions the user merely endorsed are both personal activity, so they travel as two separate lists.
  • Walkthrough: two required init members: QuestionId (UserDataExportQuestionUpvoteDTO.cs:10) and CreatedOn (:13, documented UTC).
  • Where it's used: projected by EngagementUserDataExportSection (EngagementUserDataExportSection.cs:72-76) into the QuestionUpvotes list of UserDataExportEngagementSectionDTO (UserDataExportEngagementSectionDTO.cs:32).

UserDataExportSubjectDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportSubjectDTO.cs:16 · Level 0 · record (sealed)

  • What it is: the Identity-owned half of a data-subject export: everything the Identity service holds about one account, from the login email to the MAUI device metadata. It is the Subject slot of the framework's export envelope, not the whole document.
  • Depends on: the UserIdentifierType and SpeakerIdentifierType aliases; BCL (DateTime). Notably it depends on nothing from MMCA.Common: the envelope types it plugs into declare that slot as object.
  • Concept introduced, the app-owned subject snapshot inside a framework-owned envelope. [Rubric §30, Compliance, Privacy and Data Governance] (assesses whether the right of access and portability is implemented as a real, complete, machine-readable artifact) and [Rubric §11, Security] (assesses that secrets never reach a serialization boundary). The type doc (UserDataExportSubjectDTO.cs:3-8) names the exclusions and the reason: the password hash and salt, the refresh token, and the opaque external-provider key are secrets, not portable personal data, so exporting them would create a credential-leak channel out of a privacy feature. The remarks block (:9-15) records the split that ADR-076 introduced: this type is the Subject of UserDataExportDTO in MMCA.Common.Shared.Privacy, and the cross-service data travels beside it as section envelopes, each aggregated best-effort. The framework owns the envelope, each app owns which of its own fields are portable, which is exactly why the envelope's Subject property is typed object and serializes by runtime type (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:32-40).
  • Walkthrough
    • Identity account fields (UserDataExportSubjectDTO.cs:19-34): required UserId (:19), Email (:22), FirstName (:25), LastName (:28), FullName (:31), and Role (:34).
    • External-login fields (:37, :40): IsExternalLogin and the provider name only; the doc on LoginProvider (:39) restates that the opaque provider key is intentionally omitted.
    • Cross-context link and profile (:43, :46): the nullable LinkedSpeakerId (the scalar link to a Conference speaker, never a cross-database FK) and AvatarUrl (BR-116a).
    • Device metadata (:49-67): seven nullable strings reported by the MAUI client, DeviceId, DeviceFormFactor, DevicePlatform, DeviceModel, DeviceManufacturer, DeviceName, DeviceType. This is exactly the block UserListDTO refuses to show an organizer: the subject may see their own device data, a third party browsing a user grid may not.
    • Audit timestamps (:70, :73): CreatedOn and nullable LastModifiedOn, both documented UTC, the same audit fields the framework stamps in SaveChangesAsync. ExportUserDataHandler re-stamps their DateTimeKind on the way out (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:70-73) because SQL Server hands them back as Kind=Unspecified, which would serialize without the trailing Z the doc promises; the comment at :67-69 is the record of that.
  • Why it's built this way: ADR-076 split the export handler in two, an orchestration that is identical in ADC and Store (authorize, load, fan out, degrade, envelope) and a projection that names app types and therefore cannot be shared. This record is that projection. Keeping it flat and required-heavy means the compiler catches a snapshot that forgets a field, and keeping it in Shared lets the API, the UI, and the tests name it without referencing the Domain assembly. Together with the erasure path on the User aggregate (ADR-005), it is the read half of the privacy pair: export what we hold, then anonymize it on request.
  • Where it's used: assembled by ExportUserDataHandler in one object initializer (ExportUserDataHandler.cs:48-74) and returned as the object? subject (:76); the shared base then stamps it into the envelope's Subject alongside a FormatVersion of "1.0" and the section list (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:61, :110-117). The whole envelope is served by UsersDataExportController, a subclass of the framework's DataExportControllerBase<TQuery> that supplies only the Users route and the ADC query factory (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:26-35).
  • Caveats / not-in-source: the endpoint that serves this record is feature-gated, not always on. The base carries [Authorize] and [FeatureGate(PrivacyFeatures.DataExport)] at the class level (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59-60), so GET /Users/{userId}/export (:77) returns 404 in a host that has not enabled PrivacyFeatures.DataExport. Whether any given deployed environment has that flag on is configuration, not code: Not determinable from source which environments currently serve it.

UserDataExportSubmittedQuestionDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportSubmittedQuestionDTO.cs:7 · Level 0 · record (sealed)

  • What it is: one submitted session-question row in the Engagement section of a data-subject export: the question id, the session it was asked in, and when it was submitted.
  • Depends on: the SessionQuestionIdentifierType and SessionIdentifierType aliases; BCL (DateTime).
  • Concept: the same export-row shape as UserDataExportBookmarkDTO, and the sharpest illustration of its restraint. The doc comment (UserDataExportSubmittedQuestionDTO.cs:3-6) spells the rule out: ids plus submission date only, never other users' data. The question text and its upvote count are omitted, so an export cannot be turned into a scrape of the live question feed ([Rubric §30, Compliance, Privacy and Data Governance], [Rubric §11, Security]).
  • Walkthrough: three required init members, QuestionId (UserDataExportSubmittedQuestionDTO.cs:10), SessionId (:13), and CreatedOn in UTC (:16).
  • Where it's used: carried in UserDataExportEngagementSectionDTO's SubmittedQuestions (UserDataExportEngagementSectionDTO.cs:17), populated by EngagementUserDataExportSection (EngagementUserDataExportSection.cs:41-46); the source aggregate is SessionQuestion in the Engagement live layer.

IAttendeeQueryService

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IAttendeeQueryService.cs:11 · Level 0 · interface

  • What it is: the one cross-module read contract Identity publishes. It answers a single question, "which user ids are active attendees?", so the Notification module can fan a broadcast out to every attendee without knowing anything about the User aggregate.
  • Depends on: MMCA.Common.Shared.Abstractions for the ServiceContractAttribute marker (IAttendeeQueryService.cs:1), the UserIdentifierType alias, and BCL (Task, IReadOnlyList<T>, CancellationToken). Note what is not imported: no Identity Domain, Application, or Infrastructure namespace appears anywhere in the file.
  • Concept introduced, the cross-module contract in the Shared assembly. [Rubric §7, Microservices Readiness] (assesses whether modules talk through narrow, transport-agnostic interfaces that survive extraction into separate processes) and [Rubric §3, Clean Architecture] (assesses dependency direction: the consumer depends on an abstraction, not on the producer's internals). The doc comment (IAttendeeQueryService.cs:5-9) states the rule outright: the contract lives in Shared so the Notification module can call it without depending on the Identity implementation, preserving module boundary isolation. That one placement decision is what makes three different wirings interchangeable behind the same interface: the in-process AttendeeQueryService when Identity runs in the same host, the DisabledAttendeeQueryService stub when the module is switched off, and the AttendeeQueryServiceGrpcAdapter when Identity runs as its own service. No consumer code changes between those three.
  • Concept reinforced, the [ServiceContract] marker as a machine-checked boundary. The attribute at IAttendeeQueryService.cs:10 is not decoration and it is not System.ServiceModel: it is the framework's own marker introduced in G13, whose doc records that a dedicated ServiceContractPurityTestsBase fitness rule scans every mapped assembly for types carrying it and fails the build if such a type depends on the producing service's Domain, Application, or Infrastructure layers (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:3-21). Marking this interface therefore converts "we intend to keep this extractable" from a comment into a test ([Rubric §14, Testability], [Rubric §34, Architecture Governance and Documentation]). The attribute also carries a Version that defaults to "v1" (ServiceContractAttribute.cs:39); this interface uses the parameterless constructor, so it is implicitly v1.
  • Walkthrough: one member. GetAttendeeUserIdsAsync(CancellationToken cancellationToken = default) (IAttendeeQueryService.cs:18) returns Task<IReadOnlyList<UserIdentifierType>>, documented as the identifiers of all active (non-deleted) users with the Attendee role (IAttendeeQueryService.cs:13-17). The return type is deliberately just ids, not user records: the caller needs recipients, not personal data, so the contract carries the minimum ([Rubric §30, Compliance, Privacy and Data Governance], data minimization across a module boundary).
  • Why it's built this way: a coarse, id-only, async, cancellable method maps cleanly onto a gRPC unary call, which is exactly the extraction path ADR-007 describes, and the [ServiceContract] marker names it as such. Anything richer (a filtered query object, an IQueryable) would leak Identity's persistence model across the boundary and would not survive the process split.
  • Where it's used: consumed by Notification's AttendeeNotificationRecipientProvider (which bridges it to INotificationRecipientProvider); implemented in-process by AttendeeQueryService, stubbed by DisabledAttendeeQueryService, served over the wire by AttendeesGrpcService, and satisfied remotely by AttendeeQueryServiceGrpcAdapter.

UserAdminDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserAdminDTO.cs:23 · Level 0 · record (sealed)

  • What it is: the administration-facing view of a user account, served behind the users:manage capability: id, email, name, role, creation date, lock state, and the two account-nature flags an operator needs to act on the account: whether it signs in externally and whether the email is confirmed.
  • Depends on: the UserIdentifierType alias; BCL (DateTime).
  • Concept introduced, the permission-scoped sibling DTO. [Rubric §8, Data Architecture] (assesses whether reads project only the columns a screen needs) and [Rubric §11, Security] (assesses that secrets never reach a serialization boundary). The type's remarks (UserAdminDTO.cs:8-13) states directly why this is not more fields bolted onto UserListDTO: the two DTOs are reached through different permissions, identity:users:read serves the organizer's roster, users:manage serves the administration screens. Device metadata stays out of both (attendee device privacy), and no credential material is ever projected here either, the same restraint UserDataExportSubjectDTO documents for the export path.
  • Walkthrough: nine positional parameters (UserAdminDTO.cs:23-32), each documented at :14-22: UserId, Email, FirstName, LastName, Role, CreatedOn, a nullable LockedOn, IsExternalLogin, and IsEmailConfirmed (ADR-116). Two computed members read off those without adding wire fields: IsLocked => LockedOn is not null (:35) and FullName => $"{FirstName} {LastName}" (:38).
  • Why it's built this way: this is the app-owned TUserDto closing the framework's generic administration slice. UserAdministrationService implements IUserAdministrationService<UserAdminDTO> (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Administration/UserAdministrationService.cs:43) and projects it directly inside the paged EF query, .Select(u => new UserAdminDTO(...)) after OrderBy/Skip/Take (UserAdministrationService.cs:72-86), so the excluded columns never leave the database: the same projection-as-privacy-boundary pattern UserListDTO uses for its own row. UsersAdminController closes the framework's generic controller over it, UsersAdminControllerBase<UserAdminDTO> (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersAdminController.cs:25-26), and both DI registrations name the closed type explicitly: services.TryAddScoped<IUserAdministrationService<UserAdminDTO>, UserAdministrationService>() on the Application side (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:43) and services.AddUserAdministrationUI<UserAdminDTO>() on the UI side (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:31).
  • Where it's used: produced by UserAdministrationService.ListAsync/GetAsync (UserAdministrationService.cs:53-108) and served through UsersAdminController's Admin/Users endpoints; consumed client-side as IUserAdminUIService<UserAdminDTO> on the UserDetail page (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserDetail/UserDetail.razor.cs:38, :42), and covered by unit and controller tests (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Users/UserDetail/UserDetailTests.cs, MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/Administration/UserAdministrationServiceTests.cs, MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.API.Tests/Controllers/UsersAdminControllerTests.cs).

UserAvatarDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserAvatarDTO.cs:6 · Level 0 · record (sealed)

  • What it is: the one-field response body every avatar endpoint returns: the current public avatar URL, or null when the user has none (BR-116a).
  • Depends on: nothing first-party; one BCL attribute (System.Diagnostics.CodeAnalysis.SuppressMessage).
  • Concept reinforced, the response DTO as a stable wire shape. [Rubric §9, API and Contract Design] (assesses whether endpoints return a named, versionable shape rather than a bare primitive). Returning { "avatarUrl": ... } instead of a raw string means a later addition (a thumbnail URL, an upload timestamp) is an additive change, not a breaking one. The CA1054 suppression (UserAvatarDTO.cs:5) carries its own justification in source: this is a serialized DTO field, so the URL stays a string on the wire rather than becoming a Uri.
  • Walkthrough: a single positional record, public sealed record UserAvatarDTO(string? AvatarUrl) (UserAvatarDTO.cs:6), with the parameter documented at UserAvatarDTO.cs:4. The nullable parameter is the whole contract: "no avatar" is a first-class, non-exceptional state.
  • Why it's built this way: avatars are managed file storage (ADR-045), so the only thing a client ever needs back from an upload is the new public URL. Handing it back in a named record keeps the read endpoint, the write endpoint, and the client projection on one type.
  • Where it's used: produced by the avatar read and write use cases (GetUserAvatarHandler, SetUserAvatarHandler) and returned by UsersController's me/avatar endpoints (GET at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:51 returning Ok(result.Value) at :65, POST at :79 returning at :115). The delete endpoint (:119) returns a bare ActionResult with a 204 (:133), because there is no avatar state left to report. Client-side it is deserialized by UserService in GetMyAvatarUrlAsync (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/UserService.cs:112-115) and UploadMyAvatarAsync (:139-142), both of which immediately flatten it with dto.Map(value => value.AvatarUrl ?? string.Empty) so the page never handles the envelope, only a Result<string>. The Profile page stores that string in its _avatarUrl field (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor.cs:60, :173).

UserDataExportEngagementSectionDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportEngagementSectionDTO.cs:11 · Level 1 · record (sealed)

  • What it is: the Engagement-owned payload of a data-subject export: the user's session bookmarks, submitted session questions, points ledger, check-in history, and leaderboard participation.
  • Depends on: UserDataExportBookmarkDTO, UserDataExportSubmittedQuestionDTO, UserDataExportPointsEntryDTO, UserDataExportCheckInDTO, UserDataExportPollVoteDTO, UserDataExportQuestionUpvoteDTO.
  • Concept introduced, the section payload with reachability held one level up. [Rubric §29, Resilience and Business Continuity] (assesses whether a request that fans out to peers degrades instead of failing when one peer is down) and [Rubric §7, Microservices Readiness] (assesses that a cross-service aggregate does not turn every peer into a hard dependency). The interesting move in the export is that "Engagement is unreachable" is modelled as data rather than as an exception, so a GDPR request that cannot reach one peer still succeeds. What is worth reading carefully here is where that flag lives: not on this type. The doc comment (UserDataExportEngagementSectionDTO.cs:3-10) is explicit, "reachability lives on the envelope, never here", because the framework's UserDataExportSectionDTO owns Available and UnavailableReason (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:72, :88). This type is only the payload the envelope carries in its object? Data slot (:80). That is the ADR-076 split again: degradation semantics belong to the framework, the field list belongs to the app.
  • Walkthrough: eight members, none of them required, all six collections defaulting to an empty collection expression []. Bookmarks (UserDataExportEngagementSectionDTO.cs:14), SubmittedQuestions (:17), PointsEntries (:20), CheckIns (:26, whose doc at :22-25 records that it is reported independently of the points ledger), and the two conference-day live-layer lists PollVotes (:29, UserDataExportPollVoteDTO) and QuestionUpvotes (:32, UserDataExportQuestionUpvoteDTO). IsOnLeaderboard (:35) is a plain bool and LeaderboardDisplayName (:38) a nullable string documented as null when the user never opted in, which is how the export reports a privacy choice the user made and not just the data it produced.
  • Why it's built this way: the empty-list defaults mean a section that legitimately holds nothing for the user is a truthful Complete result with an empty payload, which the framework contract distinguishes from an unknown one (IUserDataExportSection, MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:8-14). The reader of an export can therefore tell "you had no bookmarks" apart from "we could not check", and neither of those states is expressible as a null list.
  • Where it's used: constructed by EngagementUserDataExportSection from UserEngagementExportDTO (EngagementUserDataExportSection.cs:34-79) and handed to UserDataExportSectionResult.Complete(SectionName, data) (:81); ExportUserDataHandlerBase<TUser, TQuery> then wraps it into a section envelope in RunSectionAsync (ExportUserDataHandlerBase.cs:166-183) and, on a thrown call, replaces it with an Available = false envelope carrying a generic reason instead (:185-197).

UserDataExportNotificationSectionDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.DataExport · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportNotificationSectionDTO.cs:10 · Level 1 · record (sealed)

  • What it is: the Notification-owned payload of a data-subject export: the user's inbox rows, newest first.
  • Depends on: UserDataExportNotificationDTO.
  • Concept: structurally the same section payload UserDataExportEngagementSectionDTO introduces, applied to a second peer ([Rubric §29, Resilience and Business Continuity]), and carrying the same "reachability lives on the envelope, never here" contract in its doc comment (UserDataExportNotificationSectionDTO.cs:3-9). Two peers means two independent envelopes: the Notification service can be down while Engagement answers, and the export still returns everything it managed to gather, because the shared base runs each registered section through its own try/catch in a sequential loop (ExportUserDataHandlerBase.cs:105-108, :173-198). The loop is sequential on purpose, and the comment says why: the sections share the scoped unit of work and its DbContext, which is not thread-safe, and registration order is the published order of the document (:102-103).
  • Walkthrough: one member. IReadOnlyList<UserDataExportNotificationDTO> Notifications { get; init; } = [] (UserDataExportNotificationSectionDTO.cs:13), documented as newest first. The ordering is part of the contract but is produced by the peer, not enforced here.
  • Where it's used: constructed by NotificationUserDataExportSection (NotificationUserDataExportSection.cs:33-44) and returned via UserDataExportSectionResult.Complete(SectionName, data) (:46). It is the second of the two sections ADC registers, and the registration order is the document order: AddUserDataExportSection<EngagementUserDataExportSection>() then AddUserDataExportSection<NotificationUserDataExportSection>() (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:53-54, with the ordering rationale in the comment at :38-41).

DisabledAttendeeQueryService

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DisabledAttendeeQueryService.cs:7 · Level 1 · class (internal sealed)

  • What it is: the no-op stand-in for IAttendeeQueryService that gets registered when the Identity module is switched off in a host. It returns an empty attendee list instead of failing DI.
  • Depends on: IAttendeeQueryService, the UserIdentifierType alias; BCL (Task.FromResult).
  • Concept introduced, the disabled-module stub (null object). [Rubric §2, Design Patterns] (assesses recognized patterns applied deliberately: this is the Null Object pattern) and [Rubric §7, Microservices Readiness]. The module system lets a host run any subset of modules; a host that disables Identity would otherwise fail to resolve every cross-module Identity interface at startup. IModule.RegisterDisabledStubs closes that hole, and IdentityModule registers exactly this type there (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModule.cs:19-20). Crucially the stub lives in Shared, the same assembly as the contract, so a host can reference the stub without pulling in Identity's Application or Domain assemblies: the Notification service's csproj carries a comment saying precisely that (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/MMCA.ADC.Notification.Service.csproj:26-29). The class is internal sealed (DisabledAttendeeQueryService.cs:7), which is why the registration has to happen from inside this assembly's own module descriptor rather than from a host's Program.cs.
  • Walkthrough: the whole class is one expression-bodied method. GetAttendeeUserIdsAsync returns Task.FromResult<IReadOnlyList<UserIdentifierType>>([]) (DisabledAttendeeQueryService.cs:10-11): a completed task over an empty collection expression, so there is no allocation-heavy work and no async state machine.
  • Why it's built this way: returning empty rather than throwing keeps "Identity is not in this host" a configuration fact instead of a runtime error. In the extracted topology the stub is also the safety net: AddIdentityAttendeeClient() calls services.Replace(ServiceDescriptor.Scoped<IAttendeeQueryService, AttendeeQueryServiceGrpcAdapter>()) (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/DependencyInjection.cs:47), and its doc comment (:25-36) records why Replace rather than TryAdd: either the real in-process AttendeeQueryService or this stub may already be in the container, and after the call the resolved interface is the gRPC adapter in both cases (:45-46). If that replacement were ever skipped, a broadcast would reach nobody rather than crash the Notification host. The same pattern is the module convention across the repo (DisabledBookmarkCountService, DisabledSessionBookmarkValidationService, DisabledUserNotificationExportService).
  • Where it's used: registered by IdentityModule.RegisterDisabledStubs as a singleton (IdentityModule.cs:20); overwritten at startup in the Notification service, whose application-pipeline registration runs s => s.AddIdentityAttendeeClient() as its second step (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:230) with the ordering and Replace rationale spelled out in the block comment above it (:204-208).

UserDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDTO.cs:8 · Level 1 · record

  • What it is: the general-purpose account DTO: id, email, first/last name, and role. It is the credential-free projection of the User aggregate.
  • Depends on: IBaseDTO<TIdentifierType> (the framework DTO contract from MMCA.Common.Shared.DTOs, imported at UserDTO.cs:1), the UserIdentifierType alias.
  • Concept reinforced, the identified DTO (IBaseDTO<TIdentifierType>). [Rubric §9, API and Contract Design] (assesses a consistent, machine-checkable response shape) and [Rubric §11, Security] (assesses that secrets never reach a serialization boundary). Implementing IBaseDTO<TIdentifierType> (UserDTO.cs:8) is what lets this DTO plug into the framework's generic mapper and service abstractions: Id (:11) satisfies the interface. Just as important is the omission: the UserDTOMapper doc comment (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/DTOs/UserDTOMapper.cs:11) records that PasswordHash, PasswordSalt, and RefreshToken are excluded from the projection, so the DTO is the enforced boundary between an aggregate that holds credentials and anything that can be serialized.
  • Walkthrough: five required init members (UserDTO.cs:11-23), Id (:11), Email (:14, documented as the login credential, BR-200), FirstName (:17), LastName (:20), and Role as a string used for authorization decisions (:23). Because the mapper is a Mapperly source generator, adding a member here changes generated code at build time rather than at runtime (ADR-001, manual and Mapperly mapping).
  • Why it's built this way: required on every member means the compiler, not a reviewer, catches a mapper that forgets a field, and keeping the type in Shared lets any layer (API, UI, tests) name it without referencing the Domain assembly.
  • Where it's used: produced by UserDTOMapper, a [Mapper]-attributed sealed partial class (UserDTOMapper.cs:13-15) implementing IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> over (User, UserDTO, UserIdentifierType). The generator fills in MapToDTO (UserDTOMapper.cs:18); the collection overload is hand-written over it (:21-25), and a private EmailToString helper converts the Email value object to its string form (:28).

UserListDTO

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserListDTO.cs:14 · Level 1 · record (sealed)

  • What it is: the row shape for the organizer user list (BR-51): id, email, first/last name, role, creation date, and lock state, and nothing else.
  • Depends on: the UserIdentifierType alias; IUserAdminDTO (MMCA.Common.Shared.Auth.Administration, imported at UserListDTO.cs:1); BCL (DateTime).
  • Concept introduced, the list projection DTO as a privacy boundary, and now also the framework's shared roster row. [Rubric §8, Data Architecture] and [Rubric §30, Compliance, Privacy and Data Governance] for the projection; [Rubric §7, Microservices Readiness] and [Rubric §2, Design Patterns] for the interface. The type's doc comment is explicit that device-specific fields are excluded to protect attendee device privacy: the User aggregate carries DeviceId, DeviceModel, DeviceManufacturer and friends, but an organizer browsing the user grid has no business seeing them. Its remarks (ADR-116) add a second fact on top: this is also the row the framework's shared user-administration list renders, so the type answers IUserAdminDTO by reusing UserId, Email, and Role as-is, without renaming the wire contract, and maps the lock state explicitly off LockedOn (UserListDTO.cs:14). Because the projection is still built inside the query, a .Select(u => new UserListDTO { ... }) translated to SQL after Skip/Take (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:45-57), the excluded columns are never even read from the database, so this is a privacy boundary and a performance win at once ([Rubric §12, Performance and Scalability]).
  • Walkthrough: seven members (UserListDTO.cs:17-42). UserId (:17), Email (:20), FirstName (:23), LastName (:26), and Role (:29) are required init; CreatedOn is a plain init DateTime (:32). Role is a string, not the UserRole value object: the wire format stays primitive, and the closed-set type is a domain concern. The nullable LockedOn (:39, documented :34-38) is new: when an administrator locked the account, or null while it is usable, carried on the roster so an operator can see which accounts are locked without opening each one; the lock itself is changed through the Admin/Users endpoints. bool IUserAdminDTO.IsLocked => LockedOn is not null; (:42) is an explicit interface implementation, so IsLocked is reachable only through an IUserAdminDTO-typed reference, leaving the record's own public surface unchanged for every existing consumer that names UserListDTO directly.
  • Why it's built this way: keeping the list DTO separate from UserDTO lets the grid evolve (sortable columns, CreatedOn, now LockedOn) without touching the general-purpose account DTO, and it keeps the list endpoint's payload small enough to page cheaply. Counting, sorting, paging and projecting all happen at the database level in that order (GetUsersHandler.cs:39, :42, :45-56), so the DTO is the only shape that ever leaves SQL Server. Implementing IUserAdminDTO explicitly, rather than adding a second public lock-state property, lets UserListDTO also satisfy the framework's shared roster contract (ADR-116) without growing the type's own public surface: the interface exists so a framework-owned consumer can render either this DTO or UserAdminDTO (which the app-side Admin/Users screens use instead) through the same shape.
  • Where it's used: produced by GetUsersHandler inside a PagedCollectionResult<T> (GetUsersHandler.cs:62), returned by UsersController's list endpoint (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:138-141, :158), and consumed client-side through IUserUIService / UserService (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/UserService.cs:27, :64, :71-72) as the MudDataGrid row type on the UserList page, which inherits DataGridListPageBase<TDto> closed over it (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:18). The grid's filter keys are read back off the DTO with nameof(UserListDTO.Email) and friends (UserList.razor.cs:70-73, :63), so a renamed property breaks at compile time rather than at runtime. Also referenced from UserAdminDTO's own doc comment (UserAdminDTO.cs:5) and consumed from SpeakerDetail/SpeakerUserSearch in the Conference module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerDetail.razor.cs, SpeakerUserSearch.cs).

IdentityRoutePaths

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/IdentityRoutePaths.cs:6 · Level 0 · class (static)

  • What it is: the route strings and route-builder helpers the Identity UI module owns: /users, /roles, /profile, /confirm-email, plus two parameterized builders for a specific user's detail page and a specific role's edit page (ADR-116).
  • Depends on: UserIdentifierType (the Identity identifier alias, UserDetail's parameter); externals: BCL System.Globalization.CultureInfo (UserDetail's culture-invariant interpolation) and System.Uri (RoleEdit's Uri.EscapeDataString).
  • Concept reinforced, route constants as the module's public navigation surface. [Rubric §25, Navigation and Information Architecture] (assesses whether routes are declared in one place so menu entries, redirects, and tests cannot drift from the pages themselves) and [Rubric §15, Best Practices & Code Quality]. The nav items in IdentityUIModule reference IdentityRoutePaths.Profile, .Users and .Roles (IdentityUIModule.cs:20-30) rather than repeating the strings, so renaming a route is a one-line change here.
  • Walkthrough: four public static readonly string fields on a public static class (:6): Users = "/users" (:8), Profile = "/profile" (:9), Roles = "/roles" (:13, ADR-116 role-administration roster) and ConfirmEmail = "/confirm-email" (:16, the anonymous landing page for the emailed confirmation link). Two parameterized builders follow: UserDetail(UserIdentifierType userId) (:20-21), which culture-invariantly interpolates {Users}/{userId}, and RoleEdit(string role) (:29-30), which interpolates {Roles}/{Uri.EscapeDataString(role ?? string.Empty)}. The doc comment on RoleEdit (:23-27) explains why the role name IS the key: a role has no surrogate id anywhere in the system, so the registry, the grant table and the token all name it by its string, and the value is escaped because a role name can contain characters a raw URL segment cannot.
  • Caveats / not-in-source: the @page directives on UserList, Profile, RoleList and RoleEdit still spell their route literally, because a Razor @page directive requires a compile-time const, and these are static readonly fields, not const. So this type is the single source of truth for navigation and for building a link to a specific detail, not for the page routing attribute itself.
  • Where it's used: IdentityUIModule's NavItems, the Roles tests (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleListTests.cs, RoleEditTests.cs), and UserDetail's own page (UserDetail.razor.cs).

RoleEdit

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI.Pages.Roles · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Roles/RoleEdit.razor.cs:16 · Level 0 · class (partial)

  • What it is: the code-behind for the role permission-editor page: one [Parameter], the role name bound from the route.
  • Depends on: nothing beyond ComponentBase and the [Parameter] attribute (ASP.NET Core Components).
  • Concept: a minimal route-bound component parameter, the same shape used across the UI layer wherever a page's identity comes entirely from its URL segment; see IdentityRoutePaths.RoleEdit(role), the builder that constructs the link this page receives Role from.
  • Walkthrough: public string Role { get; set; } = string.Empty (RoleEdit.razor.cs:19-20), the role being edited, defaulted to empty and overwritten by route binding before first render.
  • Why it's built this way: a role has no surrogate id, so the route parameter is the role's own name; the page's Razor markup (not shown here) renders the permission editor against that name.
  • Where it's used: reached from IdentityRoutePaths.RoleEdit(role), which RoleList links to per row. Exercised by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleEditTests.cs and cross-checked for authorization by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/IdentityRouteAuthorizationTests.cs.

RoleList

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI.Pages.Roles · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Roles/RoleList.razor.cs:27 · Level 0 · class (partial)

  • What it is: the code-behind for the role-administration roster page (ADR-116); the class declaration carries no members of its own (public partial class RoleList : ComponentBase;, :27), so all behavior lives in the paired Razor markup.
  • Depends on: ComponentBase (ASP.NET Core Components) only, at this span.
  • Concept: a bare partial declaration is the minimum a routable Razor component needs when the markup file owns all state and logic; contrast with RoleEdit, whose code-behind at least carries a route parameter.
  • Why it's built this way: the roster page has no route parameters of its own (unlike the per-role editor), so there is nothing for the code-behind to declare; it exists only to anchor the @page "/roles" directive and the partial split from the .razor markup.
  • Where it's used: reached via IdentityRoutePaths.Roles, linked from IdentityUIModule's "Roles" nav item. Exercised by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleListTests.cs and IdentityRouteAuthorizationTests.cs.

ForgotPasswordCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12 · Level 1 · record (sealed)

  • What it is: the command that starts a password reset. It wraps the address the reset was requested for, and nothing else.
  • Depends on: ForgotPasswordRequest (the shared wire payload), ICommandWithRequest<out TRequest>.
  • Concept introduced, the command with no caller identity. [Rubric §11, Security] (assesses whether an anonymous surface leaks information through its shape) and [Rubric §5, Vertical Slice] (assesses whether a use case owns its own request shape). Every other user command in this module carries the caller: ChangePasswordCommand is (UserIdentifierType UserId, ChangePasswordRequest Request) and additionally marks itself ICacheInvalidating and IUserScopedCommand<ChangePasswordRequest> (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:15-16). This one carries none of that, and the record's own doc comment says why (ForgotPasswordCommand.cs:7-9): the caller has lost the credential, so there is no authenticated user id to scope it to, and the handler answers success whether or not the address holds an account. A caller-scoped marker would be a lie here, and an authorization-flavored failure would be the enumeration oracle the workflow exists to close.
  • Walkthrough: a two-line positional record with a single member (ForgotPasswordCommand.cs:12-13).
    • Request is the whole payload, so the marker interface is the load-bearing part of the declaration. Because the record implements ICommandWithRequest<ForgotPasswordRequest>, the module scan auto-registers IValidator<ForgotPasswordCommand> as a CommandRequestValidator<TCommand, TRequest> that delegates to whatever IValidator<ForgotPasswordRequest> is registered (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:121-134).
    • That request validator is ForgotPasswordRequestValidator, and it lives in the framework rather than in ADC (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11), registered by AddValidatorsFromAssemblyContaining<ClassReference>() because a module scan only sees its own assembly (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:44-48). It checks the shape of the address and nothing else, and its doc comment states the reason (ForgotPasswordRequestValidator.cs:9): a 400 that depended on whether the address had an account would be exactly the oracle the always-accepted response closes.
  • Why it's built this way: the command record stays app-side even though the workflow is entirely shared, the same split the change-password use case uses. The framework's controller and handler read it back only through ICommandWithRequest<ForgotPasswordRequest> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:61, MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:28-29), which leaves each app free to attach its own markers: ADC marks its ResetPasswordCommand ICacheInvalidating and this one deliberately carries no marker at all, because starting a reset changes no cached state.
  • Where it's used: built by PasswordResetController in its one-line factory override CreateForgotPasswordCommand (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:36) for POST Auth/forgot-password, and handled by ForgotPasswordHandler, which the controller receives as ICommandHandler<ForgotPasswordCommand, Result> (PasswordResetController.cs:29).

SetUserAvatarCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.SetUserAvatar · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarCommand.cs:12 · Level 1 · record (sealed)

  • What it is: the CQRS command that sets (uploads or replaces) the signed-in user's avatar photo (BR-116a, ADR-045). It carries the owning UserId plus the raw uploaded image bytes, and declares its own execution timeout.
  • Depends on: UserIdentifierType (the Identity identifier alias), IHasTimeout (the timeout-declaring marker interface), and BCL System.ReadOnlyMemory<byte> for the payload and System.TimeSpan for the budget.
  • Concept: the upload-security pipeline this command feeds is taught in full at SetUserAvatarHandler. What matters here is the shape of the payload: Content is a ReadOnlyMemory<byte> of raw bytes, and the doc comment (SetUserAvatarCommand.cs:5-9) is explicit that the handler validates the true format from those bytes (magic bytes), not the client-declared content type, before re-encoding to the canonical 256x256 JPEG. [Rubric §11, Security] assesses input trust boundaries: carrying an opaque byte buffer rather than a client-typed stream forces the trust decision into the handler, where the format is sniffed instead of believed. [Rubric §12, Performance and Scalability]: implementing IHasTimeout gives the pipeline an explicit per-command execution budget rather than a global default.
  • Walkthrough: a positional record SetUserAvatarCommand(UserIdentifierType UserId, ReadOnlyMemory<byte> Content) : IHasTimeout (SetUserAvatarCommand.cs:12). UserId is controller-stamped from the authenticated principal and never client-supplied; Content is the uploaded image. The body adds one member, public TimeSpan Timeout => TimeSpan.FromSeconds(60) (:19-20), whose doc comment (:13-18) states the budget covers the re-encode plus the blob PUT: the source bytes are capped by the upload limit and the stored image is a fixed 256x256 JPEG, so sixty seconds exists to bound a storage call that never returns rather than to pace legitimate work.
  • Why it's built this way: modeling the upload as an immutable command with a read-only memory buffer keeps it inside the command pipeline like any other mutation, and hands the handler a view of the bytes it can sniff and re-encode without owning a disposable stream. Declaring IHasTimeout directly on the command keeps the budget next to the payload it bounds, rather than in a configuration file the reader has to cross-reference.
  • Where it's used: constructed by UsersController.SetAvatarAsync (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:110), the [HttpPost("me/avatar")] multipart endpoint (:79). Three boundary guards run before the command is built: [Idempotent] (:80), the request ceiling [RequestSizeLimit(MaxAvatarRequestBytes)] (:81), and an explicit reject of a missing, empty or oversized file with the Avatar.InvalidUpload validation error (:92-98). Note the two distinct limits: MaxAvatarBytes = 2 * 1024 * 1024 is the image budget (BR-116a, 2 MB, :39), while MaxAvatarRequestBytes = MaxAvatarBytes + 64 * 1024 (:48) is the Kestrel request ceiling, padded by 64 KB of multipart headroom precisely so that a file at exactly 2 MB is refused by the friendly Avatar.InvalidUpload check instead of by a bare Kestrel 413 (:42-47). The stream is then buffered into a byte[] (:100-107). Handled by SetUserAvatarHandler, which returns a UserAvatarDTO carrying the new URL, and pre-screened by SetUserAvatarCommandValidator.

ConfirmEmailCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ConfirmEmail · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ConfirmEmail/ConfirmEmailCommand.cs:11 · Level 1 · record (sealed)

  • What it is: ADC's command wrapper for redeeming an email-confirmation link (ADR-116). A single-member positional record that carries the shared ConfirmEmailRequest wire payload.
  • Depends on: ConfirmEmailRequest (the shared request DTO), ICommandWithRequest<ConfirmEmailRequest> (the marker that routes it through the shared request-validator convention, the same mechanism ForgotPasswordCommand uses).
  • Concept: the same anonymous-command shape ForgotPasswordCommand teaches: the caller has no session yet (the link is followed from an email client), so the command carries only the request payload and no authenticated user id.
  • Walkthrough: public sealed record ConfirmEmailCommand(ConfirmEmailRequest Request) : ICommandWithRequest<ConfirmEmailRequest> (ConfirmEmailCommand.cs:11-12). No body; Request is the whole payload.
  • Why it's built this way: routing through ICommandWithRequest<TRequest> gets the command an auto-registered IValidator<ConfirmEmailCommand> for free via CommandRequestValidator<TCommand, TRequest>, the same convention ForgotPasswordCommand relies on, so ADC supplies no bespoke validator for this command.
  • Where it's used: constructed by EmailConfirmationController and handled by ConfirmEmailHandler. Covered by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.API.Tests/Controllers/EmailConfirmationControllerTests.cs and MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ConfirmEmail/ConfirmEmailHandlerTests.cs, and pinned by CommandValidatorCoverageTests.

HttpContextExternalLoginEmailVerifier

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Authentication · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:17 · Level 1 · class (sealed)

  • What it is: the API-edge implementation of IExternalLoginEmailVerifier. It answers one question during an in-flight OAuth callback: did the external provider assert that this login's email address is verified?
  • Depends on: IExternalLoginEmailVerifier (the Application-layer contract it satisfies), ExternalAuthExtensions for the ExternalLogin scheme name; externals: ASP.NET Core (IHttpContextAccessor, HttpContext.AuthenticateAsync, ClaimsPrincipal.FindFirst), BCL bool.TryParse.
  • Concept introduced, the fail-closed security probe implemented where the evidence lives. [Rubric §11, Security] (assesses whether an authorization-relevant decision is made on evidence the caller does not control, and what happens when the evidence is missing) and [Rubric §3, Clean Architecture] (assesses whether the direction of dependency runs inward: the interface is declared in the Application layer at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/IExternalLoginEmailVerifier.cs:11, and only this implementation knows about HTTP). The attack it exists to stop is spelled out both on the interface (IExternalLoginEmailVerifier.cs:6-9) and at the call site (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:239-244): auto-linking an external identity to an existing local account on nothing but an email match hands an attacker that account if the provider will issue tokens for an address it never confirmed. The verifier is therefore consulted before the link, and every uncertain answer is false.
  • Walkthrough: a primary-constructor class taking one dependency (HttpContextExternalLoginEmailVerifier.cs:17-18) and exposing one method.
    • EmailVerifiedClaimType (HttpContextExternalLoginEmailVerifier.cs:21), the internal const string "email_verified". internal rather than private so the claim name is nameable from the test assembly instead of being re-typed as a literal.
    • IsCurrentExternalLoginEmailVerifiedAsync() (HttpContextExternalLoginEmailVerifier.cs:24-36). It reads httpContextAccessor.HttpContext and returns false when there is none (:26-30), so a call outside a request reports unverified instead of throwing.
    • It then re-authenticates the short-lived ExternalLogin cookie (:32, ExternalAuthExtensions.ExternalLoginScheme, the constant at MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:28). That is the same principal OAuthControllerBase.CompleteAsync just authenticated (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:151), so the claim is read from the provider's own freshly minted principal, never from anything a client supplied.
    • The tail collapses three failure modes into one expression (:33-35): a null principal, a missing claim, or a value that is not a parseable true all yield false, via authenticateResult.Principal?.FindFirst(...)?.Value plus bool.TryParse(claimValue, out var verified) && verified.
  • Why it's built this way: the claim only exists because the Identity service host maps it. services.PostConfigure<GoogleOptions>(...) maps both the v3 email_verified key and the legacy v2 verified_email key onto the single email_verified claim (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:209-213), and a second PostConfigure does the same for Apple, whose id_token asserts email_verified for relays and verified addresses alike (Program.cs:218-219). The comment above the Google block records the consequence (Program.cs:203-208): GitHub's OAuth user payload asserts nothing, so a GitHub login reads as unverified by design and the link-by-email path is refused for it. Registration is services.TryAddScoped<IExternalLoginEmailVerifier, HttpContextExternalLoginEmailVerifier>() immediately after AddHttpContextAccessor() in AddModuleIdentityAPI (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:55-56), with the rationale for placing it in the API layer in the comment above (DependencyInjection.cs:51-54).
  • Where it's used: injected into AuthenticationService (AuthenticationService.cs:54) and called from the external-login path when an account with that email already exists (AuthenticationService.cs:245-246). An unverified answer returns Error.Unauthorized with the code Auth.ExternalEmailNotVerified and a message steering the user back to the local credential flow (AuthenticationService.cs:248-254); only a verified answer reaches existingUser.LinkExternalProvider(...) (AuthenticationService.cs:398). The OAuthController endpoints are the surface this runs behind.
  • Caveats / not-in-source: which providers are registered, their scopes, and the callback URLs live in the Identity service host, not here. This type only reads a claim someone else mapped.

SetUserAvatarCommandValidator

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.SetUserAvatar · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarCommandValidator.cs:17 · Level 1 · class (sealed)

  • What it is: the FluentValidation rule set for SetUserAvatarCommand (BR-116a, ADR-045). Three rules on one property: the content must be non-empty, at most 2 MB, and must sniff as a JPEG, PNG or WebP image.
  • Depends on: SetUserAvatarCommand (the validated type); ImageContentSniffer from MMCA.Common.Application (imported at SetUserAvatarCommandValidator.cs:2); the FluentValidation AbstractValidator<T> base (NuGet, imported at :1).
  • Concept, the pipeline-stage validator as a defense-in-depth copy rather than a single gate. Validators are discovered by the module's assembly scan (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58) and run by ValidatingCommandDecorator<TCommand, TResult>, which resolves every IValidator<TCommand> registered for the command (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:34), runs them sequentially because a validator may reach the database through a scoped repository and a DbContext is not thread-safe (:69-83), and converts every failure into a domain Error via ValidationFailureExtensions.ToErrors (:82, implementation at MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ValidationFailureExtensions.cs:19-21), which is what carries the WithErrorCode(...) strings below out to the client. The decorator sits in front of the transaction, so a rejected upload never opens one. [Rubric §6, CQRS and Event-Driven] assesses whether cross-cutting concerns live in the pipeline rather than in handlers: validation here is a decorator stage, not handler code. [Rubric §11, Security] assesses input trust boundaries: the same magic-byte gate exists in two places on purpose, and the class remarks (SetUserAvatarCommandValidator.cs:9-16) spell out why. [Rubric §34, Architecture Governance]: this validator is one of the entries that left the frozen-exception list in CommandValidatorCoverageTests, whose doc records that the "commands carrying real payload with no rule" group "is now empty: those were written rather than frozen, which is the only way an entry should ever leave this list" (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/CommandValidatorCoverageTests.cs:12-15).
  • Walkthrough
    • internal const int MaxAvatarBytes = 2 * 1024 * 1024 (:20), the 2 MB ceiling, documented as BR-116a (:19). It restates the constant of the same name on UsersController (.../UsersController.cs:39) rather than sharing it, because the two live in different layers.
    • Rule 1, presence (:24-27): RuleFor(x => x.Content).Must(content => !content.IsEmpty) with the message "An avatar image is required." and error code Avatar.Content.Required.
    • Rule 2, size (:29-32): content.Length <= MaxAvatarBytes, error code Avatar.Content.TooLarge, with the byte count interpolated into the message (:31).
    • Rule 3, format (:34-38): ImageContentSniffer.IsAllowedImage(content.Span), guarded by .When(x => !x.Content.IsEmpty) (:36) so an empty upload reports only the presence failure rather than both. Error code Avatar.UnsupportedFormat, the same code SetUserAvatarHandler emits from its own gate.
  • Why it's built this way: the remarks (:9-16) state the design explicitly. Both rules are "the pipeline-stage copy of limits the app already enforces, moved in front of the transaction rather than replacing anything". The size ceiling exists here because a command "can also arrive from a caller that never crossed that endpoint" (an in-process dispatch, a future gRPC or messaging entry point), so the HTTP-layer [RequestSizeLimit] is not the only defense. The format rule duplicates the handler's sniff because the handler "owns the Avatar.UnsupportedFormat contract and the re-encode that follows": the handler's check is what guarantees the invariant even if this validator were never registered, and this one just fails faster and cheaper.
  • Where it's used: never referenced by name. It is registered by the convention scan at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58 and resolved by the validating decorator as an IValidator<SetUserAvatarCommand>. A repository-wide search for the type name returns only its own declaration (:17) and its constructor (:22).
  • Caveats / not-in-source: there is no dedicated unit-test class for this validator in MMCA.ADC/Tests; its rules are exercised only indirectly. Note also that the three error codes it produces (Avatar.Content.Required, Avatar.Content.TooLarge, Avatar.UnsupportedFormat) are not the code the controller's own pre-check emits (Avatar.InvalidUpload, .../UsersController.cs:95), so an oversized upload arriving over HTTP is reported with the controller's code, and these codes surface only for a caller that bypasses the endpoint.

IdentityUIModule

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/IdentityUIModule.cs:18 · Level 7 · class (sealed)

  • What it is: the Identity module's UI descriptor. It tells the shared Blazor shell three things: which navigation entries this module contributes, which layout component to host (the terms-acceptance gate), and which assembly to scan for routable components.
  • Depends on: IUIModule (the contract, IdentityUIModule.cs:5, :13), NavItem and NavSection from MMCA.Common.UI.Common (:3), IdentityRoutePaths, RoleNames and AdministrationPermissions from MMCA.Common.Shared.Auth (:2); externals: MudBlazor Icons.Material.Filled (:5), BCL System.Reflection.Assembly (:1).
  • Concept reinforced, the pluggable UI module descriptor. [Rubric §18, UI Architecture] (assesses whether the shell discovers features rather than hard-coding them) and [Rubric §25, Navigation and Information Architecture] (assesses that menu structure, role gating, and routes are declared next to the feature that owns them). The IUIModule contract is introduced in G15: a host collects every registered implementation, merges their NavItems into the sidebar, and passes their Assembly to the router's additional assemblies so pages in a Razor Class Library are found (ADR-067). The effect is that adding the Identity module to a host adds its pages and its menu entries in one registration, with no edit to the shell. [Rubric §11, Security] and [Rubric §27, Internationalization] both show up in the three declarations below.
  • Walkthrough: three members.
    • NavItems (IdentityUIModule.cs:20-36), a collection-expression-initialized IReadOnlyList<NavItem> with three entries. "My Profile" points at IdentityRoutePaths.Profile with a Person icon in NavSection.User and no required role, so every signed-in user sees it (:19). "Users" points at IdentityRoutePaths.Users with a SupervisedUserCircle icon, passes RoleNames.Organizer as the RequiredRole positional argument, and sits in NavSection.Admin (:20), so the link is only rendered for organizers (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavItem.cs:5, :16). Note that this hides the entry: the actual enforcement is server-side on UsersController, whose list endpoint demands IdentityPermissions.UsersRead (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:139). Menu gating is UX, not authorization.
    • The third entry, "Roles" (:22-31), points at IdentityRoutePaths.Roles with an AdminPanelSettings icon, and is gated on RequiredPermission: AdministrationPermissions.ManageRoles instead of RequiredRole. The comment above it (:22-26) states the reason: role administration is the one screen whose whole point is that the role-to-capability map is editable, so an entry gated on a role name would state the thing the screen exists to let an operator change; the sidebar instead reads the permission claim the Identity host mints from its registry.
    • Every entry passes typeof(IdentityUIModule) as the TitleResource argument, which per the NavItem contract turns "Nav.MyProfile", "Nav.Users" and "Nav.Roles" into resource keys resolved against this type's resources at render time, per circuit, so the menu follows the active culture; a key the resource type does not declare renders as the raw string, which keeps a not-yet-translated entry legible instead of blank (NavItem.cs:13-18, ADR-027).
    • LayoutComponentTypes { get; } = [typeof(TermsAcceptanceGate)] (:40): contributes the invisible-until-needed TermsAcceptanceGate (from MMCA.Common.UI.Components.Legal) as a layout component, which asks a signed-in user to accept the current Terms of Service when their accepted version is behind. It overrides the contract's empty default (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IUIModule.cs:23). The source comment (:38-39) explains the placement: both the Web and the MAUI heads load the Identity UI module, so one registration covers every client.
    • Assembly => typeof(IdentityUIModule).Assembly (:42): the self-referencing assembly handle used for Blazor route discovery.
  • Why it's built this way: a descriptor class is the smallest thing that can carry declarative metadata into DI. Because it is a plain sealed class with no dependencies, it registers as a singleton and costs nothing at runtime, while keeping the sidebar's Identity section owned by the Identity module rather than by the host. Gating the Roles entry on a capability rather than a role name means the entry survives whatever role an operator later reassigns ManageRoles to.
  • Where it's used: passed as the type argument to the framework's AddUIModule<TModule>() inside DependencyInjection.AddIdentityUI (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:24). That one call does both jobs: it is the Scrutor scan root (FromAssemblyOf<TModule>) and the descriptor registration (AddSingleton<IUIModule, TModule>), at MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:323-329.

ConfirmEmailHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ConfirmEmail · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ConfirmEmail/ConfirmEmailHandler.cs:21 · Level 9 · class (sealed)

  • What it is: ADC's handler for ConfirmEmailCommand (ADR-116). Like ForgotPasswordHandler, it is a thin primary-constructor subclass with no body of its own: every step of the redeem workflow lives in the shared base.
  • Depends on: ConfirmEmailHandlerBase<TUser, TCommand> (the framework base class, MMCA.Common.Application), User, ConfirmEmailCommand, IUnitOfWork, IEmailConfirmationTokenService; externals: Microsoft.Extensions.Logging.ILogger<T>.
  • Concept: the same template-method split ForgotPasswordHandler teaches: the framework owns the entire redeem sequence (token lookup, expiry check, marking the user confirmed) and ADC supplies nothing beyond the type arguments and constructor forwarding, because confirming an email needs no app-specific lookup step the way starting a reset does.
  • Walkthrough: public sealed class ConfirmEmailHandler(IUnitOfWork unitOfWork, IEmailConfirmationTokenService tokenService, ILogger<ConfirmEmailHandler> logger) : ConfirmEmailHandlerBase<User, ConfirmEmailCommand>(unitOfWork, tokenService, logger); (ConfirmEmailHandler.cs:21-25). Three constructor parameters, all forwarded to the base; no members, no overrides visible at this span.
  • Why it's built this way: the redeem workflow is identical across every app that uses email confirmation, so the base class carries it once and each app only fixes the TUser/TCommand type arguments. This mirrors ForgotPasswordHandler's split, except that here even the one hook ForgotPasswordHandlerBase requires (finding the account by email) is not needed, because the token itself already identifies the user.
  • Where it's used: registered as ICommandHandler<ConfirmEmailCommand, Result> by the module scan (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58), and injected into EmailConfirmationController. Covered by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ConfirmEmail/ConfirmEmailHandlerTests.cs and the framework's own MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/EmailConfirmationHandlerBaseTests.cs.
  • Caveats / not-in-source: the redeem logic itself (token validation, expiry, marking the account confirmed) lives entirely in ConfirmEmailHandlerBase in MMCA.Common and is not visible in this file.

DependencyInjection

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:12 · Level 5 · class (static)

  • What it is: the one-call registration entry point for the Identity UI layer. AddIdentityUI() wires the module's entity services, its user-administration and role-administration clients (ADR-116), its bespoke user service, and its IdentityUIModule descriptor into any Blazor host.
  • Depends on: IdentityUIModule, IUserUIService / UserService from MMCA.ADC.Identity.UI.Services, UserAdminDTO (the DTO the user-administration client closes over), and the framework's AddUIModule<TModule>(), AddUserAdministrationUI<TUserAdminDTO>() and AddRoleAdministrationUI() extensions from MMCA.Common.UI; externals: Microsoft.Extensions.DependencyInjection.IServiceCollection (:1).
  • Concept reinforced, the extension(IServiceCollection) registration block. [Rubric §15, Best Practices and Code Quality] (assesses idiomatic, current-language composition) and [Rubric §3, Clean Architecture] (assesses that each layer owns its own wiring instead of the host reaching into it). This file is the UI-layer instance of the convention used across all four repos: instead of a classic public static IServiceCollection AddX(this IServiceCollection services), the method lives inside a C# preview extension(IServiceCollection services) block (DependencyInjection.cs:14) and the receiver is named once for the whole block, which the class doc calls out explicitly (:7-10). Callers see an ordinary services.AddIdentityUI().
  • Walkthrough: AddIdentityUI() (DependencyInjection.cs:20-42) does three things and returns services for chaining (:41).
    • services.AddUIModule<IdentityUIModule>() (:24), the shared two-step prologue every module's Add{Module}UI() opens with, with the intent restated in the comment above it (:22-23). Inside MMCA.Common.UI that single call runs the Scrutor scan over the descriptor's assembly, registering every IEntityService<TEntityDTO, TIdentifierType> implementation AsImplementedInterfaces with a scoped lifetime, then registers the descriptor with AddSingleton<IUIModule, TModule> (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:323-329). Convention over configuration: a new standard CRUD-shaped UI service needs no registration edit here.
    • services.AddUserAdministrationUI<UserAdminDTO>() (:31), the framework's client for the shared Admin/Users endpoints, gated server-side on users:manage. The comment above it (:26-30) is explicit that this is closed over ADC's own UserAdminDTO, which the user detail page reads and the shared user-administration list acts through, and that it is a different resource under a different permission from the "Users" roster nav entry, not more methods on that roster's client.
    • services.AddRoleAdministrationUI() (:36), the framework's client for the Admin/Roles endpoints, gated server-side on roles:manage. The comment above it (:33-35) explains it is non-generic, unlike the user-administration client, because a role is a string and a permission is a string, so there is no ADC DTO to close over.
    • An explicit services.AddScoped<IUserUIService, UserService>() (:39). The comment above it (:38) explains why this one is hand-written: users are a custom contract, not an IEntityService, so the scan cannot pick it up (the same asymmetry IUserUIService documents, because the Users API returns UserListDTO rather than an IBaseDTO-shaped entity DTO and exposes only list plus delete).
  • Why it's built this way: one host-facing method per module keeps the three hosts symmetric: they each call AddIdentityUI() and nothing else. The mix of shared scan helpers and generic and non-generic administration clients is deliberate, and the framework doc for AddUIModule<TModule>() (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:308-314) records the ordering rationale: module-specific services are registered by the caller after the prologue, so a module whose service must win over a shared default still controls its own registration order. The email-confirmation client (IEmailConfirmationUIService / EmailConfirmationService, ADR-116) is no longer registered here: DependencyInjection.cs:38-42 (prior revision) dropped it, leaving AddIdentityUI() with three registrations instead of four.
  • Where it's used: called by all three UI hosts, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:168 (Blazor Server), MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:56 (the WebAssembly client), and MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:130 (MAUI), which is what lets the same Razor Class Library render on web and mobile.
  • Caveats / not-in-source: the Identity module ships one DependencyInjection class per layer (this UI one plus the API, Application, and Infrastructure copies covered in sibling parts of this chapter). They all slug to the bare dependencyinjection anchor, which resolves to the first occurrence in the assembled chapter, so cross-references disambiguate by layer in prose.

ForgotPasswordHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:21 · Level 14 · class (sealed)

  • What it is: ADC's start-a-password-reset handler. Like the export handler it is a thin subclass of a shared workflow, and it supplies exactly one app-specific step: how to find an ADC account from an email address without tracking it.
  • Depends on: ForgotPasswordHandlerBase<TUser, TCommand>, User, ForgotPasswordCommand, IUnitOfWork, IPasswordResetTokenService, IEmailSender, PasswordResetSettings, Email; externals: IOptions<T>, ILogger<T>.
  • Concept introduced, the success-always workflow. [Rubric §11, Security] (assesses whether an anonymous endpoint's responses and logs leak which accounts exist) and [Rubric §2, Design Patterns] (template method again, with a single abstract member). The base is worth reading end to end (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:36-101), because every exit from it is Result.Success(): a malformed address (:60-61), an address with no account (:68-69), a request the token service throttled (:75-76), and an email send that threw (:90-95) all log a reason and report success, exactly like the happy path (:98-99). The remarks state the rule outright (ForgotPasswordHandlerBase.cs:22-25): only the request validator can produce a 400, and it only inspects the shape of the address, so nothing about the response distinguishes a registered address from an unregistered one.
    • The logging is part of that contract rather than an afterthought. The base declares its own [LoggerMessage] methods, and PasswordResetRejected takes a plain reason string and neither an address nor an account id (ForgotPasswordHandlerBase.cs:165-166), and the comment above it says why (ForgotPasswordHandlerBase.cs:163-164): the log must not become the enumeration oracle the responses are not. The two log lines that do carry a user id, PasswordResetRequested and PasswordResetEmailFailed (:157-158, :160-161), are only reachable once an account has already been resolved.
    • The send-failure branch is a small resilience decision worth copying (ForgotPasswordHandlerBase.cs:91-96): the token has already been issued and is still valid, so the user can simply retry, and reporting the SMTP failure to the caller would be an oracle of a different kind ([Rubric §29, Resilience & Business Continuity]).
  • Walkthrough: a primary-constructor class over five dependencies, every one of them forwarded straight to the base (ForgotPasswordHandler.cs:21-27), plus a single override.
    • FindUntrackedByEmailAsync(Email email, CancellationToken) (ForgotPasswordHandler.cs:30-34) is the whole ADC contribution. It takes a read repository off the unit of work, UnitOfWork.GetReadRepository<User, UserIdentifierType>(), and calls FirstOrDefaultAsync with u => u.Email == email and asTracking: false (:30-33).
    • Three details in those four lines. The predicate compares the Email value object rather than a raw string, so normalization is the value object's job and not a lowercase call here. asTracking: false is correct because this handler never mutates the account: it only needs the id to mint a token against, and the redeem side is a separate use case (ResetPasswordHandler). And the unit of work is reached through the base's protected UnitOfWork property, which exists precisely so the lookup override has a repository to reach (ForgotPasswordHandlerBase.cs:46).
    • The base declares this one member abstract for a stated reason (ForgotPasswordHandlerBase.cs:28-31, abstract member at :109): each app's User stores the address differently, so resolving an account by email is the only step the framework cannot write. Everything else, including the email body, is shared. The handler's own remarks note that this mirrors the untracked lookup AuthenticationService already uses for login (ForgotPasswordHandler.cs:16-20, and see AuthenticationService.cs:91-95).
  • Why it's built this way: the reset credential is a cache record rather than three new columns on the hottest table in the system, so this handler needs no migration and no sweeper: IPasswordResetTokenService owns the token, its lifetime, and the per-email throttle. The knobs live in PasswordResetSettings, bound from the PasswordReset section with ValidateDataAnnotations().ValidateOnStart() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:160-163), which is what turns a bad TokenLifetimeMinutes (default 30, MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:29) into a startup failure rather than a runtime surprise. One default is deliberately permissive: ResetUrl defaults to the empty string (PasswordResetSettings.cs:25), and an unconfigured host degrades to a token-only email the user pastes into the reset page by hand rather than shipping a broken link (ForgotPasswordHandlerBase.cs:139-148).
  • Where it's used: registered as ICommandHandler<ForgotPasswordCommand, Result> by the module scan, which finds it through the base class's interface exactly as it finds the export handler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58). It is injected into PasswordResetController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:29), whose inherited POST Auth/forgot-password action is [AllowAnonymous], [Idempotent], rate-limited by the auth-ip policy, and answers 202 Accepted on every success (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:75-92). Covered by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ForgotPasswordHandlerTests.cs, whose three cases pin the shipped behavior: a registered address issues a token and sends (:67), an unknown address still succeeds without issuing one (:86), and the lookup runs untracked (:100).
  • Caveats / not-in-source: what actually delivers the mail is not visible here. IEmailSender is resolved from the host, so whether a reset email leaves the process depends on the Identity service's SMTP configuration, which this type neither reads nor validates.

SetUserAvatarHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.SetUserAvatar · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:27 · Level 15 · class (sealed, partial)

  • What it is: the command handler that uploads or replaces the signed-in user's avatar (BR-116a, ADR-045). It sniffs the true image format, re-encodes to a canonical 256x256 JPEG, stores the result under a fresh random blob name, persists the new URL on the User aggregate, then schedules the previous blob for deletion through the durable internal-command queue (ADR-114). It returns a UserAvatarDTO with the new URL.
  • Depends on: MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload> (base class, which supplies the ICommandHandler<in TCommand, TResult> implementation); MutationContext; IUnitOfWork; IImageProcessor; IFileStorageService; IInternalCommandScheduler; DeleteAvatarBlobInternalCommand; ImageContentSniffer; User; UserAvatarDTO; Result; Error; externals Microsoft.Extensions.Logging (ILogger plus [LoggerMessage] source generation), Microsoft.Extensions.DependencyInjection (IServiceScopeFactory, for the failure-path cleanup scope), System.Guid, System.IO.MemoryStream, System.Uri, System.Globalization.CultureInfo.
  • Concept introduced, safe handling of a user-supplied binary upload (ADR-045). This is the one place in the Identity module that accepts arbitrary bytes from an end user, and it treats them as hostile. [Rubric §11, Security] assesses input trust boundaries and defense against malicious uploads: the accepted format is decided by magic-byte sniffing (ImageContentSniffer.IsAllowedImage, called at SetUserAvatarHandler.cs:63, implemented at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/ImageContentSniffer.cs:15-16 as JPEG or PNG or WebP, each with its own byte-signature test at :21-36), never the client-declared content type, and the image is re-encoded rather than stored as received, which the class doc (:14-19, specifically :16) notes strips EXIF and kills polyglots (a payload that is a valid image and a valid script). [Rubric §30, Compliance, Privacy and Data Governance]: the same re-encode drops geolocation and camera EXIF a user did not intend to publish, and the stored URL lands on a [Pii]-tagged property (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:118-120), so it participates in the erasure story of ADR-005.
  • Concept introduced (second), the templated mutation workflow with a side-data context. This handler writes almost no plumbing: the base MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> owns the load-mutate-save sequence in MutateCoreAsync (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:277-328): resolve the repository (:280), load the aggregate (:281), answer Error.NotFound stamped with the handler name when it is missing (:282-283), stamp the caller's rowversion when the endpoint is conditional (:291-296, ADR-035), run the subclass mutation (:298), skip the save on an idempotent no-op (:304-305), save (:316), then call LogMutated (:318) and the post-commit OnMutatedAsync (:319). Subclasses fill in hooks. The MutationContext is the mechanism that lets one hook hand a value to a later one: a Dictionary<string, object?> bag (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutationContext.cs:33) with Set<TValue> (:56), type-checked TryGet<TValue> (:69) and GetOrDefault<TValue> (:93). [Rubric §2, Design Patterns] assesses whether recurring shapes are factored into a named abstraction: this is Template Method with an explicit, typed side channel for the value that crosses the commit (the replaced blob's name). The one value the overridden HandleAsync must read after a failed run, the blob this request just uploaded, lives instead in a single private field, _uploadedBlobName (:42-47): set by the mutation, cleared by the post-commit hook, and (per its doc) still set when the save fails, which is how the handler knows the new blob has been orphaned. [Rubric §15, Best Practices & Code Quality]: the interesting lines of this handler are the image work and the orphan bookkeeping, not the repository and save ceremony. [Rubric §13, Observability and Operability]: the success path logs through a compile-time [LoggerMessage] source-generated method (:206-207), as do both scheduling-failure warnings (:209-210, :212-213), which is why the class is partial.
  • Walkthrough
    • Primary-constructor injection of IUnitOfWork, IImageProcessor, IFileStorageService, IInternalCommandScheduler, an IServiceScopeFactory and an ILogger<SetUserAvatarHandler> (SetUserAvatarHandler.cs:27-33), with the unit of work forwarded to the base MutateEntityPayloadHandlerBase<SetUserAvatarCommand, User, UserIdentifierType, UserAvatarDTO> (:34). Two constants: internal const int AvatarSize = 256, the canonical edge length (:37), and private const string PreviousBlobNameKey = "Avatar.PreviousBlobName", the context key the replaced blob's name travels under (:40). One field: private string? _uploadedBlobName (:47), the blob this request uploaded until the row pointing at it is committed.
    • EntityId (:50) tells the base which aggregate to load: command.UserId.
    • Format gate, before the aggregate is loaded (:57-69): the async HandleAsync override first runs ImageContentSniffer.IsAllowedImage(command.Content.Span) (:63) and returns Error.Validation with code "Avatar.UnsupportedFormat" and the source stamped as the handler name (:65-68) without touching the base. The doc (:52-55) gives the reason: an unsupported upload is answered with the format error whether or not the account exists, so the response cannot be used to probe for account existence.
    • Orphan guard around the base workflow (:71-89): the comment (:71-73) states the problem. The new blob is uploaded before the row is saved, so a save that throws, or a failure returned after the upload, leaves a blob no row points at. The override resets _uploadedBlobName (:74), awaits base.HandleAsync (:77), and when the result is a failure with the field still set it schedules that blob's cleanup before returning the original result unchanged (:78-83). A thrown exception is caught only when the field is set (exception filter at :85), the cleanup is scheduled, and the exception is rethrown (:87-88), so the caller sees exactly the outcome it would have seen without the guard.
    • The mutation (:93-130), which runs with the aggregate already loaded and tracked. The bytes are wrapped in a non-writable MemoryStream (:99) and handed to imageProcessor.NormalizeToSquareJpegAsync(content, AvatarSize, ...) inside an await using block (:101-104); a failed Result<byte[]> re-returns its own errors (:106-109), so the failure detail from the image layer reaches the client unchanged and the save never happens (the base short-circuits at MutateEntityHandlerBase.cs:305-306). Nothing has been uploaded yet on this path, so the field stays null and no cleanup is scheduled.
    • Upload (:111-123): a fresh eight-hex-character suffix comes from Guid.NewGuid().ToString("N")[..8] (:111), and the blob name is built culture-invariantly with string.Create(CultureInfo.InvariantCulture, $"{command.UserId}-{suffix}.jpg") (:112). The JPEG is streamed to fileStorage.UploadAsync(blobName, jpeg, "image/jpeg", ...) (:117); a failed upload short-circuits the same way (:120-123).
    • Record the new blob, hand the old one forward, then mutate (:125-127): _uploadedBlobName = blobName (:125) marks the fresh upload as an orphan candidate until commit; context.Set(PreviousBlobNameKey, TryGetBlobName(entity.AvatarUrl)) captures the blob this write is about to orphan before the property is overwritten (:126); then entity.SetAvatarUrl(uploaded.Value!.AbsoluteUri) (:127, domain setter at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:394). The base commits.
    • LogMutated (:133-134) fires the source-generated LogAvatarSet at Information level with the message "User {UserId} avatar updated" (:206-207).
    • OnMutatedAsync (:141-166), the post-commit hook: it first clears _uploadedBlobName (:147-148), because the row now points at the new blob and it is no longer an orphan candidate. It then reads context.GetOrDefault<string>(PreviousBlobNameKey) (:150) and returns immediately when it is null (:151-154). Otherwise it schedules a DeleteAvatarBlobInternalCommand through internalCommandScheduler.ScheduleAsync(...) (:158-160) instead of deleting the blob inline; a scheduling failure is logged via LogAvatarBlobScheduleFailed at Warning (:162-165) rather than surfaced to the caller, since the mutation itself already committed. The comment above the call (:156-157) explains why: no transaction is active this late, so the row is already written and signalled immediately, and the internal-command processor picks the delete up on its next tick rather than waiting for the poll interval.
    • BuildResult (:169-173) shapes the handler's answer from the mutated aggregate: Result.Success(new UserAvatarDTO(entity.AvatarUrl)).
    • ScheduleUploadedBlobCleanupAsync (:180-198), the failure-path cleanup: it opens a fresh async DI scope (:184), resolves its own IInternalCommandScheduler from it (:185), and schedules a DeleteAvatarBlobInternalCommand for the orphaned new blob with CancellationToken.None (:186-188). The doc (:175-179) gives both reasons: the request scope's context is the one whose save just failed, and a cancelled request token must not cancel the cleanup. A failed Result logs LogAvatarBlobScheduleFailed (:189-192) and a thrown exception logs LogAvatarBlobScheduleThrew with the exception (:194-197); neither replaces the original outcome.
    • TryGetBlobName (:200-204): an internal static helper that parses the stored URL with Uri.TryCreate, requires more than one segment, and returns the unescaped final segment as the blob name (otherwise null). It is deliberately internal static because RemoveUserAvatarHandler reuses it.
  • Why it's built this way: the random blob-name suffix means a replacement never reuses the old URL, so browser and CDN caches self-resolve without an explicit purge (class doc, :17-18). Doing the blob work inside the mutation and deferring the cleanup to a durable queue is the ADR-096 shape combined with the ADR-114 durable-job pattern, as the class remarks (:20-26) state: an upload that fails stops the write before the row is touched, and the blob the write orphans is handed to the internal-command queue rather than deleted inline, so a storage outage or a crash in the post-commit tail defers the delete instead of leaking an image with nothing left that knows about it. The orphan guard in HandleAsync covers the other direction: a fresh upload whose save then fails or throws is queued for deletion through the same command, from a scope and a token that the failed request cannot affect. Sniffing plus mandatory re-encoding is the ADR-045 rule that an accepted upload is only ever stored in a shape the server itself produced.
  • Where it's used: dispatched by UsersController.SetAvatarAsync (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:109-111, injected as ICommandHandler<SetUserAvatarCommand, Result<UserAvatarDTO>> at :33); the returned UserAvatarDTO carries the new URL back for immediate display. The type is also referenced from RemoveUserAvatarHandler, DeleteUserHandler and SetUserAvatarCommandValidator. Unit-tested by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/SetUserAvatar/SetUserAvatarHandlerTests.cs.
  • Caveats / not-in-source: the concrete resize and crop strategy behind NormalizeToSquareJpegAsync, and the storage provider behind IFileStorageService, live in MMCA.Common and are not visible here: this handler only orchestrates them. The 2 MB per-upload size limit is enforced outside this file, at the API boundary (.../UsersController.cs:81, :92-98) and in SetUserAvatarCommandValidator (:29-32); the comment at :61-62 says the size limit stays app-side. The actual blob deletion never runs inside this handler: both the post-commit hook and the failure-path cleanup only schedule DeleteAvatarBlobInternalCommand; its processor and retry behavior live in the internal-command infrastructure, not here. A failed or thrown scheduling call is logged and otherwise swallowed, so a blob can still be left behind when the queue itself is unavailable (both warning messages say it "must be removed by hand", :209, :212). The _uploadedBlobName field makes the handler instance stateful for the duration of one call; that is safe only while each call gets its own handler instance, a lifetime set by DI registration outside this file.

ChangePasswordRequestValidator

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.Validation · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/ChangePasswordRequestValidator.cs:11 · Level 1 · class (sealed)

  • What it is: the FluentValidation rule set for a password change: the current password must be present, and the new password must clear the shared strength rules.
  • Depends on: ChangePasswordRequest (the shared request DTO it validates), StrongPasswordRules<T>; externals: FluentValidation (AbstractValidator<T>, RuleFor, Include).
  • Concept introduced, the request validator that reaches the pipeline indirectly. [Rubric §6, CQRS & Event-Driven] (assesses whether cross-cutting concerns sit in the pipeline rather than inside handlers) and [Rubric §11, Security] (assesses that password strength is enforced at a boundary the caller cannot bypass). Nothing in ADC constructs this class: it is picked up by the convention scan in the Identity Application layer's AddModuleIdentityApplication (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58, ScanModuleApplicationServices<ClassReference>()) and registered as IValidator<ChangePasswordRequest> by the AddValidatorsFromAssembly call inside that scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:117). The Validating decorator, though, validates the command, not the request. The bridge is the framework's CommandRequestValidator<TCommand, TRequest> (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:30), which does RuleFor(c => c.Request).SetValidator(validator) (CommandRequestValidator.cs:39) and is closed over any command implementing ICommandWithRequest<TRequest> by a reflection loop in the same scan (DependencyInjection.ModuleScanning.cs:121-135). ChangePasswordCommand declares that marker (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:16), so writing this one small validator is enough to put password rules in front of the handler with no wiring at all.
  • Walkthrough: one constructor (ChangePasswordRequestValidator.cs:13-19).
    • RuleFor(x => x.CurrentPassword).NotEmpty() with the message "Current password is required." and the error code User.CurrentPassword.Required (ChangePasswordRequestValidator.cs:15-16). The explicit WithErrorCode matters: the API surfaces codes, not English text, so a client can branch on the code and a translator can own the message.
    • Include(new StrongPasswordRules<ChangePasswordRequest>(x => x.NewPassword)) (ChangePasswordRequestValidator.cs:18). Include folds another rule set into this one against a selected property, so the framework owns "what counts as a strong password" in one place and every app inherits changes to it.
  • Why it's built this way: the current password is deliberately only checked for presence here, never for strength. Its correctness is a credential comparison against the stored hash, which lives in ChangePasswordHandler via the framework's ChangePasswordHandlerBase, and an old account may legitimately hold a password that no longer meets today's rules. Applying strength rules to it would lock those users out of the very screen that would fix the problem.
  • Where it's used: resolved as IValidator<ChangePasswordRequest> by CommandRequestValidator<ChangePasswordCommand, ChangePasswordRequest>, which the Validating decorator runs before ChangePasswordHandler for the password-change endpoint on AuthController.

ExportUserDataQuery

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataQuery.cs:12 · Level 2 · record (sealed)

  • What it is: the request for a data-subject export (PRIVACY.md §7): which account to export, plus who is asking and with what role.
  • Depends on: IUserOwnedRequest, the UserIdentifierType alias.
  • Concept introduced, carrying the caller inside the query. [Rubric §11, Security] (assesses whether authorization decisions are made on trusted, explicit inputs) and [Rubric §5, Vertical Slice] (assesses whether a use case owns its own request shape). Three positional members (ExportUserDataQuery.cs:12-15): UserId, CurrentUserId, and the nullable CurrentUserRole. The handler never reaches for HttpContext; the controller reads the claims once through ICurrentUserService and hands them to the query factory (UsersDataExportController, MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:32-35). That is what makes the whole use case testable without a web host, and it is why the ownership rule can live in the shared base rather than in a controller filter. Implementing IUserOwnedRequest is the load-bearing part: the framework's generic constraint is where TQuery : IUserOwnedRequest (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:55), so the shared UserOwnershipRule can read UserId, CurrentUserId, and CurrentUserRole off any app's query record (ExportUserDataHandlerBase.cs:81-86).
  • Walkthrough: a positional record with an XML doc per parameter and nothing else (ExportUserDataQuery.cs:5-15). What it deliberately does not implement is as informative as what it does: it carries no IQueryCacheable, so the Caching decorator has nothing to act on. The base handler's remarks say why (ExportUserDataHandlerBase.cs:42-45): the document it produces is PII by design and must never be logged or cached.
  • Why it's built this way: the nullable CurrentUserRole mirrors reality at the edge, where a role claim may simply be absent. The privilege check is therefore written to accept null and answer false rather than to assume a role exists (ExportUserDataHandler.HasExportPrivilege, ExportUserDataHandler.cs:38).
  • Where it's used: constructed by UsersDataExportController for GET Users/{userId}/export (UsersDataExportController.cs:32-35) and handled by ExportUserDataHandler, which the controller receives as IQueryHandler<ExportUserDataQuery, Result<UserDataExportDTO>> (UsersDataExportController.cs:27).

SelfHttpWarmupTask

MMCA.ADC.Identity.Service · MMCA.ADC.Identity.Service · MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:23 · Level 2 · class (sealed, internal)

  • What it is: the Identity service's startup warm-up. Once Kestrel is listening, it replays the hot users read against the host's own cleartext endpoint so routing, authentication, and the middleware pipeline are JIT-compiled before the first real request arrives.
  • Depends on: SelfHttpWarmupTaskBase from MMCA.Common.Aspire.Warmup; externals: ASP.NET Core (IServer, IHostApplicationLifetime, IHostEnvironment), IConfiguration, ILogger<T>.
  • Concept reinforced, the cold-start warm-up task (ADR-025). [Rubric §12, Performance & Scalability] (assesses whether first-request latency after a scale-out or restart is managed rather than paid by a user) and [Rubric §13, Observability & Operability] (assesses that readiness reflects the instance's real ability to serve). The base class, taught in group-16, discovers the bound address, waits for the host lifetime, and issues the configured GETs; the runner registered by AddWarmupReadiness() keeps /health/ready not-ready until the task has had its chance, so the orchestrator does not route traffic into a cold instance. Failures are logged and fall back to lazy warm-up, so a warm-up problem degrades startup latency rather than blocking the service (SelfHttpWarmupTask.cs:13-16).
  • Walkthrough: a primary-constructor class that forwards all five dependencies to the base (SelfHttpWarmupTask.cs:23-29) and overrides three members.
    • Paths (SelfHttpWarmupTask.cs:33-36), one entry: users?pageNumber=1&pageSize=10. The comment above it ties the string to reality (SelfHttpWarmupTask.cs:31-32): it is the exact shape UserService.GetPagedAsync builds for the organizer list once the empty filter and sort values are dropped, leaving only the paging pair.
    • Name => "SelfHttpWarmup" (SelfHttpWarmupTask.cs:39) and WarmupPaths => Paths (SelfHttpWarmupTask.cs:42), the base's two abstractions.
    • RequireSuccessStatusCode => false (SelfHttpWarmupTask.cs:49), the one genuinely interesting override. UsersController is [Authorize] plus [HasPermission], so an unauthenticated self-request is refused by design and returns 401. The comment (SelfHttpWarmupTask.cs:44-48) makes the argument: the refusal still traverses Kestrel, routing, authentication, and the whole middleware pipeline, which is exactly where the cold-start JIT cost lives, so a 401 is the correct outcome and treating it as a failure would log a spurious warning on every single startup.
  • Why it's built this way: the class doc (SelfHttpWarmupTask.cs:6-17) draws the contrast with Conference's task, which warms public read endpoints and therefore also populates the output cache. Identity has no anonymous read to warm, and its host sets a NoCache base output-cache policy anyway (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:168), so this task buys pipeline JIT only and says so rather than pretending to prime a cache it cannot reach.
  • Where it's used: registered with services.AddWarmupTask<SelfHttpWarmupTask>() in the Identity service host (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:174), where the surrounding comment block (Program.cs:170-173) repeats the rationale for an operator reading the host file.
  • Caveats / not-in-source: the base type decides how the bound address is discovered and whether the run is skipped under the Testing environment; this file only supplies the paths and the status-code policy.

UserDeleted

MMCA.ADC.Identity.Domain · MMCA.ADC.Identity.Domain.Users.DomainEvents · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/DomainEvents/UserDeleted.cs:10 · Level 2 · record (sealed)

  • What it is: the in-process domain event the User aggregate raises when an account is soft-deleted (BR-56). It carries the user id and nothing else.
  • Depends on: BaseDomainEvent, the UserIdentifierType alias.
  • Concept reinforced, the domain event as an internal fact. [Rubric §4, DDD] (assesses whether state changes that other code cares about are expressed as named domain facts instead of inferred from a database write) and [Rubric §6, CQRS & Event-Driven]. The distinction from an integration event is the one to keep straight, and this module makes it unusually concrete: there is a second UserDeleted record, in MMCA.ADC.Identity.Shared.Users.IntegrationEvents, carrying the same fact to other processes. A BaseDomainEvent such as this one is dispatched in-process by the framework's domain-event dispatcher after SaveChangesAsync (deferred until after commit when a transaction is open), while a BaseIntegrationEvent leaves its outbox row unprocessed for the OutboxProcessor to publish to the broker (ADR-003). Same AddDomainEvent call at the aggregate, two very different delivery paths, chosen purely by base type.
  • Walkthrough: a positional record with one member, public sealed record class UserDeleted(UserIdentifierType UserId) : BaseDomainEvent (UserDeleted.cs:10-12). The id-only payload is deliberate: an in-process subscriber can load whatever else it needs from the same unit of work, and a fat payload would go stale between raise and dispatch. Note the absence of an [EventName] attribute, which the two Shared integration events both carry: a domain event never crosses a wire, so it needs no stable published name.
  • Why it's built this way: raising the event inside User.Delete (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:490) and only when the base soft-delete actually succeeded (User.cs:487-491) means a second delete on an already-deleted account raises nothing, so subscribers cannot see a duplicate fact. The override does nothing else, and its doc comment explains the omission (User.cs:479-483): outstanding refresh sessions live in the RefreshSessions table and the refresh flow re-fetches the account through the soft-delete query filter, so every session stops working the moment this commits, with no explicit revocation call needed here.
  • Where it's used: raised by User.Delete (User.cs:485-493) and asserted by the domain tests (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Users/UserInvariantsAndRoleTests.cs:271-281).
  • Caveats / not-in-source: no IDomainEventHandler<UserDeleted> exists anywhere in ADC source today. The doc comment names cascade cleanup and audit logging as the intended consumers (UserDeleted.cs:6-7), but that is an available extension point, not shipped behavior. The cross-process cleanup that is shipped travels on the Shared integration event of the same name, raised separately by DeleteUserHandler; the two records are not connected in code.

UserPasswordChanged

MMCA.ADC.Identity.Domain · MMCA.ADC.Identity.Domain.Users.DomainEvents · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/DomainEvents/UserPasswordChanged.cs:9 · Level 2 · record (sealed)

  • What it is: the in-process domain event raised when a user's credentials are replaced (BR-204).
  • Depends on: BaseDomainEvent, the UserIdentifierType alias.
  • Concept: structurally identical to the domain UserDeleted, and the same domain-event-versus-integration-event distinction applies. What is worth noticing is the omission: the payload is the id only (UserPasswordChanged.cs:9-11), never the new hash or salt. A security-relevant event that carried credential material would turn every future subscriber, and every log line that serialized it, into a leak ([Rubric §11, Security]).
  • Walkthrough: public sealed record class UserPasswordChanged(UserIdentifierType UserId) : BaseDomainEvent (UserPasswordChanged.cs:9-11).
  • Where it's used: raised by User.ChangePassword after the two credential invariants pass and the new hash and salt are assigned (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:402-418, with the raise at :307), and asserted by the domain tests (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Users/UserTests.cs:173).
  • Caveats / not-in-source: like the domain UserDeleted, no handler subscribes to it in ADC source today. Session revocation on a password change is not driven from this event, and the aggregate itself no longer carries a refresh token to revoke: sessions live in their own table (User.cs:480-482).

UserDeleted

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.IntegrationEvents · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:26 · Level 3 · record (sealed)

  • What it is: the cross-service announcement that an account has been erased (UC-21, BR-56). Same name as the Identity domain event above, different assembly, different delivery path, different job.
  • Depends on: BaseIntegrationEvent, EventNameAttribute, the UserIdentifierType alias.
  • Concept introduced, the erasure announcement, and why its payload is nearly empty. [Rubric §30, Compliance, Privacy & Data Governance] (assesses whether an erasure reaches every store that holds the subject's personal data) and [Rubric §11, Security]. Two positional members only (UserDeleted.cs:26-29): UserId and DeletedOn. The doc comment gives the reason in one sentence (UserDeleted.cs:17-21): a downstream module reacting to an erasure already stores the scalar user id, and carrying a name or an email here would publish the very personal data the erasure exists to remove, onto a broker that persists messages. An erasure event that leaked PII would be self-defeating.
  • Concept introduced, the wire name is not the CLR name. [EventName("Identity.UserDeleted.v1")] (UserDeleted.cs:25) pins the string the broker and the outbox use, so the two same-named records can never collide on the wire and a future namespace move or rename cannot break a running consumer. The version suffix is what makes an incompatible payload change expressible as a new name rather than as a silent break (ADR-010).
  • Walkthrough: the interesting mechanics are not in the record, they are at the raise site. DeleteUserHandler calls user.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow())) inside OnAfterSoftDeleteAsync, before the erasure is saved (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:43-59), and the record's own doc spells out what that buys (UserDeleted.cs:7-10): the outbox row is written by the very SaveChangesAsync that commits the soft-delete and the anonymization, so an account that is erased has always produced exactly one event, and an erasure that rolls back produces none. Publishing after the commit instead would leave a crash window in which the account is gone and the published name is not. The timestamp comes from the injected TimeProvider (DeleteUserHandler.cs:33), not DateTimeOffset.UtcNow, so the handler stays testable.
  • Why it's built this way: the doc comment (UserDeleted.cs:11-16) is explicit that this record does not replace the in-process domain UserDeleted: Engagement is a different process with its own database and never sees an Identity in-process dispatch. Two records carrying one fact is the honest modelling of a two-process reality, and the base type is the only thing that decides which path a raise takes (ADR-003).
  • Where it's used: consumed by Engagement's UserDeletedPointsHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:38-45), which takes the account off the public leaderboard and overwrites the LeaderboardOptIn display name the attendee had published there, the one place Engagement holds a name rather than a scalar id (UserDeletedPointsHandler.cs:13-20). The subscription is wired in the Engagement service host with x.RegisterIntegrationEventConsumer<UserDeleted>() (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:297). That handler is idempotent in both directions and deliberately does not swallow exceptions, so a failed erasure is retried by the outbox and the broker instead of being acked away with a log line (UserDeletedPointsHandler.cs:23-28).

IUserUIService

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI.Services · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/IUserUIService.cs:17 · Level 3 · interface

  • What it is: the client-side contract the Identity Blazor pages call for everything user-shaped: the organizer user list, account delete, the three avatar operations on the signed-in user, and the data-subject export download. Every member returns a Result.
  • Depends on: UserListDTO, Result and ErrorType, the UserIdentifierType alias; BCL (Task, Stream, CancellationToken).
  • Concept introduced, the bespoke UI service contract (when the generic one does not fit). [Rubric §18, UI Architecture] (assesses whether components talk to a typed abstraction instead of raw HTTP) and [Rubric §9, API & Contract Design]. The framework's default for a UI-to-API contract is IEntityService<TEntityDTO, TIdentifierType>, which assumes a full CRUD resource whose DTO implements IBaseDTO<TIdentifierType>. The doc comment states plainly why users cannot use it (IUserUIService.cs:7-10): the users API returns UserListDTO, a projection with no IBaseDTO identity, and it exposes only list plus delete rather than the standard five verbs. Rather than bend the DTO to fit a generic contract, the module declares its own narrow interface. The UI registration documents the consequence: the assembly scan behind AddUIModule<IdentityUIModule>() only finds IEntityService<,> implementations, so this one is registered by hand on the next line (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:22-39).
  • Concept introduced, the Result-typed UI contract. [Rubric §24, Forms/Validation/UX Safety] (assesses whether a page can render a server-side failure as UI rather than as an unhandled exception). The second paragraph of the doc comment is the rule (IUserUIService.cs:11-15): every member returns a Result carrying the API's own errors with their ErrorType intact, so a page branches on the outcome instead of catching, and only the caller's own OperationCanceledException still escapes. That single sentence is what removes try/catch from the Identity pages.
  • Walkthrough: six members.
    • GetPagedAsync(...) (IUserUIService.cs:22-31) takes nullable email, firstName, lastName, role filters, pageNumber/pageSize defaulting to 1 and 10, and nullable sortColumn/sortDirection. It returns Result<(IReadOnlyList<UserListDTO> Items, int TotalItems)> rather than the wire's PagedCollectionResult<T>, so the page gets exactly the two things a MudDataGrid needs, wrapped in the outcome type.
    • DeleteAsync(UserIdentifierType userId, ...) (IUserUIService.cs:36) returns a bare Result; the doc records that the server requires owner-or-Organizer (UC-21), so the client never has to reason about the rule.
    • GetMyAvatarUrlAsync (IUserUIService.cs:42), UploadMyAvatarAsync(Stream content, string fileName, string contentType, ...) (IUserUIService.cs:47), and RemoveMyAvatarAsync (IUserUIService.cs:50), the BR-116a trio. Note the "my" naming: none of them take a user id, because the server derives the subject from the token. A client that cannot name another user's avatar cannot accidentally ask for one. The avatar getter is documented as a success carrying an empty string when no avatar is set (IUserUIService.cs:39-40), which keeps "nothing set" out of the failure channel entirely.
    • ExportAsync(UserIdentifierType userId, ...) (IUserUIService.cs:52-60) downloads the data-subject export (GET Users/{userId}/export, PRIVACY.md section 7), the JSON document the API serves as a file. Unlike the avatar trio it does take a user id (the signed-in user on the profile page), and the doc records that the API enforces owner-or-Organizer. It returns Result<(byte[] Content, string? FileName)>: the raw bytes plus the server's suggested file name, null when the server sent none, so a page can hand both straight to a browser download. The export is produced server-side by ExportUserDataHandler and surfaced by LegalAndDataCard.
  • Why it's built this way: an interface here is what lets the Profile and UserList pages be tested with a fake instead of a live HTTP stack ([Rubric §14, Testability]), and it is the boundary that keeps HttpClient, retries, and JSON shapes out of the components entirely.
  • Where it's used: implemented by UserService; injected into UserList (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:34) and Profile (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor.cs:20); registered scoped in AddIdentityUI (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:39).

NotificationUserDataExportSection

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/NotificationUserDataExportSection.cs:18 · Level 3 · class (sealed)

  • What it is: the Notifications contribution to a data-subject export: the user's notification inbox rows, fetched from the Notification service and projected into the export's own DTO shape.
  • Depends on: IUserDataExportSection (the contract), UserDataExportSectionResult, IUserNotificationExportService (the cross-service peer), UserDataExportNotificationSectionDTO, UserDataExportNotificationDTO.
  • Concept introduced, the export section as a pluggable contributor. [Rubric §30, Compliance, Privacy & Data Governance] (assesses whether a data-subject access request can actually be satisfied across every store that holds the subject's data) and [Rubric §7, Microservices Readiness] (assesses that a module reaches a peer through an interface it could satisfy in-process or over the wire). An access request is only as complete as the list of places that answer it, and in ADC those places are separate processes with separate databases. The framework's answer is a small interface, IUserDataExportSection (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:20), with a stable SectionName that appears verbatim in the document a subject reads (IUserDataExportSection.cs:27) and one ExportAsync per user (:38). Sections accumulate through AddUserDataExportSection<TSection>(), which registers them scoped and via TryAddEnumerable so a double registration adds one entry (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.Extensibility.cs:41-43), and they are exported in registration order (IUserDataExportSection.cs:16-17).
  • Walkthrough: a primary-constructor class over one dependency (NotificationUserDataExportSection.cs:18-19).
    • SectionName => "Notifications" (NotificationUserDataExportSection.cs:22). Treat it as contract text, not a label: it is the key a subject (or a regulator) reads in the JSON.
    • ExportAsync (NotificationUserDataExportSection.cs:25-47) awaits GetUserNotificationExportAsync(userId, ...) on the peer (:29-31), then projects each row into a UserDataExportNotificationDTO with NotificationId, Title, SentOn, IsRead, ReadOn, and ScopeKey (:35-43), wrapped in a UserDataExportNotificationSectionDTO (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportNotificationSectionDTO.cs:10). ScopeKey is the event-scoping key each notification carries (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportNotificationDTO.cs:30), and exporting it is what lets a subject see which event a notification belonged to rather than an undifferentiated list. The re-projection is what keeps the exported wire shape owned by Identity.Shared rather than by whatever the peer happens to return today.
    • UserDataExportSectionResult.Complete(SectionName, data) (:46) closes it out. A user with an empty inbox produces an empty list and a Complete result, which is a truthful "nothing here" rather than the ambiguous "could not tell" an Unavailable would report (IUserDataExportSection.cs:9-13).
  • Why it's built this way: the class doc (NotificationUserDataExportSection.cs:11-15) makes the omission explicit: transport failures are not caught here. Catching them locally would mean every section reinventing the degrade policy; letting them propagate one frame lets the base of ExportUserDataHandler apply one policy to all sections ([Rubric §29, Resilience & Business Continuity]).
  • Where it's used: registered second, after Engagement, in AddModuleIdentityApplication (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:54), which fixes its position in the document; the peer client is wired in the Identity service host with services.AddNotificationUserExportClient() inside the application-pipeline chain (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:327), resolving to a gRPC adapter outside the Notification process. Covered by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/NotificationUserDataExportSectionTests.cs.

UserRegistered

MMCA.ADC.Identity.Shared · MMCA.ADC.Identity.Shared.Users.IntegrationEvents · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:36 · Level 3 · record (sealed)

  • What it is: the cross-service announcement that a new account exists. It carries the database-generated user id plus the identity fields another context needs to match on, and it is the event that drives the BR-207 speaker auto-link.
  • Depends on: BaseIntegrationEvent, EventNameAttribute, the UserIdentifierType alias.
  • Concept introduced, the integration event as a published contract. [Rubric §6, CQRS & Event-Driven] (assesses whether contexts collaborate through facts rather than commands), [Rubric §7, Microservices Readiness] (assesses that the producer does not know its consumers), and [Rubric §9, API & Contract Design]. Four properties of this record make it a contract rather than an internal message. It lives in Shared, the assembly a consumer may reference without touching Identity's Domain or Application. It carries denormalized primitives (string Email, string FirstName, string LastName, string Role) rather than the Email value object or UserRole, so a subscriber needs no Identity types to deserialize it. It is a record with positional members, so adding an optional member later is an additive change consumers can ignore, which is exactly how EmailVerified arrived. And it declares its wire name explicitly, [EventName("Identity.UserRegistered.v1")] (UserRegistered.cs:35), so the published contract survives any CLR-side rename (ADR-010).
  • Concept introduced, an additive member that gates a downstream side effect. [Rubric §9, API & Contract Design], [Rubric §11, Security]. The sixth positional member, bool EmailVerified = false (UserRegistered.cs:36-43), is what makes the BR-207 speaker auto-link safe rather than merely convenient: the type's own doc comment says nothing proves ownership of an address typed into the registration form, so a speaker profile may only be handed out when an external provider asserted the address as verified. Because the member is additive with a false default, an outbox row written before it existed, or one still in flight across a deploy, deserializes to the value that links nothing, so the rollout cannot silently grant auto-links it should not.
  • Walkthrough: six positional members (UserRegistered.cs:36-43): UserId, Email, FirstName, LastName, Role, EmailVerified. Email is the field the auto-link actually matches on; the two name fields exist so a subscriber can report candidates without a call back into Identity; EmailVerified is always false for a local password registration, whose address is unproven, and reflects the external provider's own assertion for an OAuth registration.
  • Why it's built this way: the ordering problem is the whole story. The id is a database-generated identity column, so the event cannot be raised inside User.Create (the factory remarks say so outright, MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:202-208): the id is still default at that point, and the outbox serializes the payload at capture time, so it would persist UserId = 0, which the cross-service Conference consumer cannot resolve (AuthenticationService.cs:31-38). Instead AuthenticationService wraps the base registration workflow in a single UnitOfWork.ExecuteInTransactionAsync (AuthenticationService.cs:81-88), then in OnUserRegisteredAsync raises the event on the already-persisted aggregate and saves a second time (AuthenticationService.cs:188-193). The first save populates the real id; the second save writes the outbox row; both are inside one transaction, so the user and the event commit atomically. The remarks on that override (AuthenticationService.cs:119-129) name the consequence honestly: this is eventual consistency, and the token handed back to the just-registered user does not yet carry the speaker_id claim. The same raise happens for brand-new external OAuth users, guarded by an isNewUser flag (AuthenticationService.cs:321-331), and it is that path that can populate EmailVerified = true.
  • Where it's used: consumed by Conference's UserRegisteredHandler, which runs the email-match speaker auto-link and publishes SpeakerLinkedToUser back so Identity can set User.LinkedSpeakerId; Identity registers the consumers for that return event in its own host (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:330-331).
  • Caveats / not-in-source: the type's own doc comment says the event is "Published by AuthenticationService AFTER the unit-of-work commit" (UserRegistered.cs:7-9), and the User.Create remarks repeat the phrasing (User.cs:203-207); neither still describes the mechanism. Current source raises it via AddDomainEvent on the aggregate after the first save, inside the transaction, so the outbox row commits with the insert (AuthenticationService.cs:134-135). The code is ground truth here; both doc comments predate the atomicity fix described at AuthenticationService.cs:31-41. Whether UserRegisteredHandler currently reads EmailVerified to gate the auto-link, or the flag is carried on the wire ahead of that consumer-side change, is not determinable from this unit's source.

SendEmailConfirmationCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.SendEmailConfirmation · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SendEmailConfirmation/SendEmailConfirmationCommand.cs:20 · Level 4 · record (sealed)

EngagementUserDataExportSection

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/EngagementUserDataExportSection.cs:19 · Level 4 · class (sealed)

  • What it is: the Engagement contribution to a data-subject export: session bookmarks, submitted session questions, the points ledger, check-in history, live-poll votes, question upvotes, and leaderboard participation, read from the Engagement service and projected into the export's DTOs.
  • Depends on: IUserDataExportSection, UserDataExportSectionResult, IUserEngagementExportService, UserDataExportEngagementSectionDTO and its six row types (UserDataExportBookmarkDTO, UserDataExportSubmittedQuestionDTO, UserDataExportPointsEntryDTO, UserDataExportCheckInDTO, UserDataExportPollVoteDTO, UserDataExportQuestionUpvoteDTO).
  • Concept reinforced, the export section (introduced at NotificationUserDataExportSection), here at its widest. [Rubric §30, Compliance, Privacy & Data Governance] and [Rubric §9, API & Contract Design]. Everything structural is identical to its sibling: a primary constructor over one peer interface, a constant SectionName (EngagementUserDataExportSection.cs:23), one ExportAsync, and no local try/catch (EngagementUserDataExportSection.cs:12-16). What differs is breadth, six collections instead of one, which is what makes its two projection comments worth reading.
  • Walkthrough: ExportAsync (EngagementUserDataExportSection.cs:26-82) makes a single peer call (:30-32) and then assembles one section DTO (:34-79).
    • Bookmarks (:36-40) and SubmittedQuestions (:41-46) project scalar ids plus CreatedOn. No session or question titles are pulled across: Engagement stores ids, and the export is truthful about what Engagement actually holds.
    • PointsEntries (:47-55) converts ActivityType with .ToString(), and the comment says why (:49-50): the export document is read by the data subject, so an activity travels as its readable name rather than an enum number. That is a real API-design decision, because it means renaming an enum member changes an externally visible document. The row also carries Points and SubjectKey (:52-53), the scalar ledger facts.
    • CheckIns (:56-65) applies the same rule to Scope (:58-60) and carries the nullable EventId, SessionId, and SponsorId alongside CheckedInOn, which is what makes the three check-in scopes distinguishable in the output.
    • PollVotes (:66-71) projects LivePollId, OptionId, and CreatedOn, and QuestionUpvotes (:72-76) projects QuestionId and CreatedOn. Both are id-only rows like the bookmarks, so the export states which poll option and which question the subject voted on without pulling any poll or question text across.
    • IsOnLeaderboard and LeaderboardDisplayName (:77-78) close the section. Those are the same two facts Engagement's UserDeletedPointsHandler erases on account deletion, which is the neat symmetry of this module: access and erasure operate on the identical set of data.
    • UserDataExportSectionResult.Complete(SectionName, data) (:81).
  • Why it's built this way: registration order is document order, and Engagement is registered first (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:49-54), with the comment above the two calls recording that this is deliberate rather than incidental. Stable ordering means an exported document can be diffed across runs.
  • Where it's used: the peer client is wired in the Identity service host with services.AddEngagementUserExportClient() as one step of the AddMmcaApplicationPipeline chain (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:324-327), which resolves to a gRPC adapter outside the Engagement process. The host comment states the coupling explicitly (Program.cs:304-311): both export clients are best-effort consumers, so a peer that stays unreachable degrades one section instead of failing the export, and Identity never hard-depends on either peer. Covered by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/EngagementUserDataExportSectionTests.cs.

TokenPermissionGrants

MMCA.ADC.Identity.Service · MMCA.ADC.Identity.Service.Authorization · MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Authorization/TokenPermissionGrants.cs:34 · Level 4 · class (static)

  • What it is: the composition point that applies every module's permission-grant map to one host's shared PermissionRegistryBuilder, used by the Identity Service host that issues tokens for all four modules.
  • Depends on: PermissionRegistryBuilder, IdentityPermissionGrants, ConferencePermissionGrants, EngagementPermissionGrants, NotificationPermissionGrants; externals: Microsoft.Extensions.DependencyInjection.IServiceCollection.
  • Concept reinforced, an extension block as a DI registration surface. The workspace-wide convention of C# extension types for DI (CLAUDE.md) shows up here as extension(IServiceCollection services) (TokenPermissionGrants.cs:36) exposing AddTokenPermissionGrants().
  • Walkthrough: AddTokenPermissionGrants() (TokenPermissionGrants.cs:44-45) calls services.AddPermissions(ApplyAll). ApplyAll(PermissionRegistryBuilder permissions) (TokenPermissionGrants.cs:53-59) calls each module's own Apply(permissions) in turn: Identity, Conference, Engagement, Notification. Its doc (TokenPermissionGrants.cs:48-51) notes it is exposed separately so a test can build the same registry without booting a web host.
  • Why it's built this way: a token-issuing service needs to know every module's permission set, not just its own, so the token it mints carries claims another module's API can authorize against; the grant maps accumulate and union, so calling this alongside the Identity module's own registration is additive and order-independent (TokenPermissionGrants.cs:38-42).
  • Where it's used: registered by the Identity Service host's composition root.

RegisterRequestValidator

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.Validation · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:12 · Level 7 · class (sealed)

  • What it is: the rule set for a registration request: a well-formed email within the domain's length limit, a strong password, required first and last names, and an optional address that is validated only when present.
  • Depends on: RegisterRequest, EmailRules<T>, StrongPasswordRules<T>, RequiredStringRules<T>, AddressValidator, UserInvariants; externals: FluentValidation.
  • Concept reinforced, composing shared rule sets, and tying them to the domain's constants. [Rubric §6, CQRS & Event-Driven], [Rubric §15, Best Practices & Code Quality]. This validator is almost entirely Include calls: the four reusable rule sets from MMCA.Common.Application.Validation carry the actual logic, and this class only chooses which property each applies to. The detail worth copying is that the length arguments are not literals: UserInvariants.EmailMaxLength, UserInvariants.FirstNameMaxLength, and UserInvariants.LastNameMaxLength (RegisterRequestValidator.cs:16,18,19) come straight from the aggregate's own rules. A request can therefore never be accepted at the edge and then rejected by the domain for a length the API thought was fine.
  • Walkthrough: one constructor (RegisterRequestValidator.cs:14-24).
    • EmailRules<RegisterRequest>(x => x.Email, "Email", UserInvariants.EmailMaxLength) (RegisterRequestValidator.cs:16): presence, format, and length, with "Email" as the display name in messages.
    • StrongPasswordRules<RegisterRequest>(x => x.Password) (RegisterRequestValidator.cs:17), the same complexity policy ChangePasswordRequestValidator applies to a new password.
    • Two RequiredStringRules<RegisterRequest> for the names (RegisterRequestValidator.cs:18-19).
    • The conditional address rule (RegisterRequestValidator.cs:21-23): RuleFor(x => x.Address).SetValidator(new AddressValidator()!).When(x => x.Address is not null). When is what makes the address genuinely optional: omitting it is valid, supplying a malformed one is not.
  • Why it's built this way: registration is anonymous and internet-facing, so it is the most hostile input surface in the module. Composing framework-owned rule sets means a tightening of the shared password policy applies here without a code change ([Rubric §11, Security]).
  • Where it's used: auto-registered as IValidator<RegisterRequest> by ScanModuleApplicationServices<ClassReference>() (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58) and injected into AuthenticationValidators, which AuthenticationService receives through its base and runs at the top of the registration workflow. The endpoint is POST auth/register on AuthController.

ExportUserDataHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:30 · Level 9 · class (sealed)

  • What it is: ADC's data-subject export handler. It is a thin subclass of the framework's export workflow that answers exactly two app-specific questions: which role may export somebody else's account, and which of the account's own fields count as portable personal data.
  • Depends on: ExportUserDataHandlerBase<TUser, TQuery>, User, ExportUserDataQuery, UserRole, UserDataExportSubjectDTO, IUnitOfWork, IUserDataExportSection; externals: TimeProvider, ILogger<T>.
  • Concept introduced, the template-method handler where the app supplies only its vocabulary. [Rubric §30, Compliance, Privacy & Data Governance] (assesses whether access and portability are implemented once and identically everywhere), [Rubric §2, Design Patterns] (template method), and [Rubric §1, SOLID] (the base is closed for modification and open through three protected hooks). The base (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:49) owns the whole workflow: the owner-or-privileged-role check via UserOwnershipRule.CheckOwnership, returning a User.ExportForbidden error (ExportUserDataHandlerBase.cs:80-90), a no-tracking load through GetReadRepository with Error.NotFound when the account is gone (:92-98), the subject snapshot, the section fan-out (:104-108), and the envelope stamped with CurrentFormatVersion "1.0" and a GeneratedOn from TimeProvider (:61, :110-117). Two of those choices carry real weight. The fan-out is sequential on purpose, because sections share the scoped unit of work and its DbContext, which is not thread-safe, and because registration order is the published order of the document (:102-103). And each section runs inside its own try/catch (:173-198) that degrades a failing section to Available = false with a deliberately generic reason, sending the exception detail to the log and never to the subject (:185-197); OperationCanceledException is explicitly excluded, because a cancelled request is not a degraded one.
  • Walkthrough: a primary constructor forwarding four dependencies to the base (ExportUserDataHandler.cs:30-35) and two overrides.
    • HasExportPrivilege(string? currentUserRole) => UserRole.IsOrganizer(currentUserRole) (ExportUserDataHandler.cs:38). One line, and the case-insensitive comparison lives inside UserRole.IsOrganizer rather than here, so a claim with unexpected casing cannot silently deny an organizer (a test pins the lowercase "organizer" case, MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserData/ExportUserDataHandlerTests.cs:110).
    • BuildSubjectSnapshotAsync (ExportUserDataHandler.cs:41-77) guards its argument (:46) and projects the aggregate into a UserDataExportSubjectDTO: identity and profile fields, IsExternalLogin and LoginProvider, LinkedSpeakerId, AvatarUrl, the seven MAUI device fields, and the two audit timestamps (:48-73). What is absent is the point: no PasswordHash, no PasswordSalt, no refresh token, no external ProviderKey. The class doc states the principle (ExportUserDataHandler.cs:16-19): credentials are secrets, not portable personal data, and a portability right is not a right to a copy of your own password hash.
    • The timestamp handling is the subtle bit (ExportUserDataHandler.cs:67-73). SQL Server hands audit timestamps back as Kind=Unspecified, and the DTO documents them as UTC but serializes them without a Z marker in that state, so DateTime.SpecifyKind(..., DateTimeKind.Utc) only restores the marker on a value that was already UTC. LastModifiedOn is null-checked first, since a never-updated account has none.
    • The method returns Task.FromResult<object?>(subject) (:76): the hook is asynchronous because some apps read a second aggregate for the snapshot, and ADC does not need to.
  • Why it's built this way: the handler needs no DI registration of its own. ScanModuleApplicationServices<ClassReference>() finds it through the base class's IQueryHandler<TQuery, Result<UserDataExportDTO>> implementation, which both the Identity DI comment (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:51-52) and the framework's own registration remarks record (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.Extensibility.cs:32-35), and a dedicated test pins it (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserData/ExportUserDataRegistrationTests.cs:29). Best-effort section aggregation is the same posture the module takes for the best-effort live-channel publish (ExportUserDataHandler.cs:23-28): one peer outage costs the subject one section, never the whole document ([Rubric §29, Resilience & Business Continuity]).
  • Where it's used: injected into UsersDataExportController as IQueryHandler<ExportUserDataQuery, Result<UserDataExportDTO>> (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:27) and invoked from the inherited GET Users/{userId}/export action, which the framework's DataExportControllerBase<TQuery> declares [Authorize] and feature-gates on PrivacyFeatures.DataExport (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59-60, action at :77-82), returning the document as a file download (:109). Covered by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserDataHandlerTests.cs and, at the controller edge, MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.API.Tests/Controllers/UsersDataExportControllerTests.cs.
  • Caveats / not-in-source: ADC does not override OnExportCompletedAsync, the base's post-assembly hook for an access-log row or a metric (ExportUserDataHandlerBase.cs:159), so an export leaves no application-level audit record beyond ordinary request logging.

SendEmailConfirmationHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.SendEmailConfirmation · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SendEmailConfirmation/SendEmailConfirmationHandler.cs:27 · Level 14 · class (sealed)

  • What it is: ADC's concrete handler for SendEmailConfirmationCommand, supplying only the one piece the shared framework base cannot know: how to look up a User by email.
  • Depends on: SendEmailConfirmationHandlerBase<TUser, TCommand>, User, IUnitOfWork, IEmailConfirmationTokenService, IEmailSender, EmailConfirmationSettings, SendEmailConfirmationCommand; externals: Microsoft.Extensions.Options, Microsoft.Extensions.Logging.
  • Concept reinforced, the template-method handler. [Rubric §2, Design Patterns]: the framework base class owns the whole send workflow (token issuance, email dispatch, logging) and declares one abstract extension point, FindUntrackedByEmailAsync; ADC's job is a two-line override, not a reimplementation. This is the same shape as ChangePasswordHandler over ChangePasswordHandlerBase<TUser, TCommand>.
  • Walkthrough: the primary constructor forwards unitOfWork, tokenService, emailSender, settings, logger straight to the base (SendEmailConfirmationHandler.cs:25-26). FindUntrackedByEmailAsync (:29-33) queries UnitOfWork.GetReadRepository<User, UserIdentifierType>() with asTracking: false for the first User whose Email matches, returning null when there is none.
  • Why it's built this way: reading untracked (asTracking: false) is deliberate: the confirmation-send path only needs to read the user to email a token, never to mutate the entity, so skipping EF's change tracker avoids paying its cost for a query that will not be saved.
  • Where it's used: registered as the handler for SendEmailConfirmationCommand; invoked by EmailConfirmationController and AuthController.

UserService

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI.Services · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/UserService.cs:21 · Level 4 · class (sealed)

  • What it is: the HTTP implementation of IUserUIService. It builds the query string, attaches the bearer token, applies the shared retry policy, and turns every response (success or failure) into a Result.
  • Depends on: IUserUIService, AuthenticatedServiceBase, ITokenStorageService, HttpResultExecutor, ProblemDetailsResultReader, UserListDTO, UserAvatarDTO, PagedCollectionResult<T>; externals: IHttpClientFactory, MultipartFormDataContent, HttpStatusCode.
  • Concept introduced, the two-halves rule for an honestly typed client. [Rubric §18, UI Architecture], [Rubric §26, Front-End Security] (assesses that tokens are attached by one audited helper rather than by each caller), and [Rubric §29, Resilience & Business Continuity]. Every method here is the same sandwich: HttpResultExecutor.ExecuteAsync on the outside, ProblemDetailsResultReader.ReadAsync on the inside. The reader converts a response into a Result, parsing the API's ProblemDetails and preserving the original ErrorType where the API emitted the MMCA error array (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:50-56); the executor converts the absence of one, a refused connection, a DNS failure, a dropped socket, an HttpClient timeout (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:13-17). Both halves are needed before a service method can honestly claim to return a Result. The one exception is deliberate and stated: when the caller's own token is why the operation stopped, the OperationCanceledException is rethrown, because a disposed component or a superseded grid fetch is not an error to render (HttpResultExecutor.cs:18-24, :100-103); a timeout raises the same exception type with the token not cancelled, and that one does become a failure (:104-107). The base class supplies CreateAuthenticatedClientAsync() and RetryPolicy, so the token never appears in this file, which is the point.
  • Walkthrough: six methods over a single Endpoint = "users" constant (UserService.cs:24).
    • GetPagedAsync (UserService.cs:27-73) puts all eight parameters into a dictionary, drops the null or whitespace entries, and URL-encodes the survivors with Uri.EscapeDataString (UserService.cs:38-52). That filter is why SelfHttpWarmupTask warms exactly users?pageNumber=1&pageSize=10: with no filters set, only the paging pair survives. The body is read back as PagedCollectionResult<UserListDTO> (:64-65) and then flattened with result.Map(...) into the (Items, TotalItems) tuple the grid binds to (:71-72), with the comment noting that the pagination metadata still travels with the page and only its shape changes (:69-70). Mapping rather than unwrapping is what keeps a failure a failure all the way to the page.
    • DeleteAsync (UserService.cs:76-93) builds users/{id} with CultureInfo.InvariantCulture (an id must never be culture-formatted), retries, and returns the reader's bare Result.
    • GetMyAvatarUrlAsync (UserService.cs:96-117) is the one method with a status-code special case: a 404 short-circuits to Result.Success(string.Empty) (:107-110), because "no avatar set" is not a failure and an empty URL renders as the profile's fallback icon (:105-106). Anything else goes through the reader and is mapped to AvatarUrl ?? string.Empty (:112-115).
    • UploadMyAvatarAsync (UserService.cs:120-144) posts a MultipartFormDataContent with one file part carrying the caller's content type (:131-134). It deliberately does not use the retry policy, and the comment says why (:130): the content stream is single-shot, since file-picker and file-input streams do not rewind, so a retry would post an empty body. This is the sharpest example in the module of a resilience policy being wrong for a specific call.
    • RemoveMyAvatarAsync (UserService.cs:147-158) deletes and returns the reader's Result.
    • ExportAsync (UserService.cs:161-190) builds users/{id}/export with CultureInfo.InvariantCulture and retries like the other reads. It is the one method that does not run a success through the reader: the success body is the file itself, not a JSON DTO, so only a non-success response goes through ProblemDetailsResultReader.ReadAsync and is re-wrapped with Result.Failure<...>(failure.Errors) (:177-181). On success it reads the bytes and takes the file name from the Content-Disposition header, preferring FileNameStar over FileName and trimming quotes (:183-185), so the name is null when the server sent none.
  • Why it's built this way: manual query-string assembly, rather than a typed query object, keeps the client honest about what the API actually accepts. Returning Result rather than throwing is what lets the pages branch: Profile uses TryGetValue on the avatar read (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor.cs:58), IsSuccess on the avatar removal (Profile.razor.cs:176), and IsFailure on the account delete (Profile.razor.cs:211); the removal and the delete sit in a try that catches only the OperationCanceledException of component disposal (Profile.razor.cs:186, Profile.razor.cs:221), so a failed call is a branch on the result, never a caught exception.
  • Where it's used: registered as IUserUIService in AddIdentityUI (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:39) and consumed by UserList (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:75,89,98) and Profile (Profile.razor.cs:166) through the interface, so the same code runs on Blazor Server, WebAssembly, and MAUI.

DeleteAvatarBlobInternalCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.DeleteAvatarBlob · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommand.cs:17 · Level 4 · record (sealed)

UserClaimsController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UserClaimsController.cs:16 · Level 5 · class (sealed)

  • What it is: a single read-only endpoint that echoes the authenticated caller's JWT claims back as a JSON object (UC-10). It is the "who does the server think I am" diagnostic.
  • Depends on: ApiControllerBase; externals: ASP.NET Core MVC ([ApiController], [Authorize], HttpContext.User), Asp.Versioning.
  • Concept reinforced, the thin diagnostic controller. [Rubric §13, Observability & Operability] assesses whether an operator can inspect live state without attaching a debugger, and [Rubric §11, Security] covers what such a surface is allowed to reveal. The security property to notice is that the endpoint reflects only HttpContext.User (UserClaimsController.cs:28), the principal the authentication middleware already built from the presented token. It reads nothing from the database and accepts no id, so the worst a caller can learn is what they already hold. [Authorize] (UserClaimsController.cs:15) makes an anonymous call a 401 rather than an empty object.
  • Walkthrough: one action, GetClaims() (UserClaimsController.cs:26-39). It groups the principal's claims by type (UserClaimsController.cs:28-29) and projects each group into a dictionary entry whose value is either the single string or the list of strings (UserClaimsController.cs:34-35). That collapse is what makes the JSON pleasant: single-valued claims such as the identifier or email serialize as scalars, while a genuinely repeated claim stays an array instead of being silently truncated. The doc comment (UserClaimsController.cs:18-22) names the expected set and one non-obvious detail: the user identifier rides the token's sub, which the bearer handler surfaces as nameidentifier.
  • Why it's built this way: routing is [Route("[controller]")] (UserClaimsController.cs:13), so the resource is /userclaims, and [ApiVersion("1.0")] (UserClaimsController.cs:14) puts it under the same header-based versioning as every other ADC endpoint (ADR-046). Returning ActionResult with an anonymous dictionary rather than a named DTO is a deliberate exception to the module's DTO discipline: the payload is by definition whatever claims the token happens to carry, so there is no stable shape to name. The two ProducesResponseType entries (UserClaimsController.cs:24-25) therefore declare status codes only.
  • Where it's used: reachable through the Gateway alongside the other Identity endpoints; it is a support and debugging surface rather than something the ADC UI calls in a normal flow.

LegalAndDataCard

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI.Pages.Users.Profile · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/LegalAndDataCard.razor.cs:28 · Level 5 · class (partial)

  • What it is: the "Legal and your data" card of the profile page: it shows the user's Terms of Service standing, links the legal documents, and downloads the data-subject export. It is the sub-component Profile renders as <LegalAndDataCard UserId="_userId.Value" /> (Profile.razor:109), extracted so the page's code-behind stays a container.
  • Depends on: IUserUIService (ExportAsync, LegalAndDataCard.razor.cs:172), ILegalAcceptanceUIService (resolved optionally, :119), LegalAcceptanceDTO, LegalSettings via IOptions (:54), ViewerTimeZone (:62), IExternalLinkService and IShareService (:55-56), IToastService, and IJSRuntime for the browser save (:57).
  • Concept introduced (1), one export action, two delivery paths by head. [Rubric §22, Responsive & Cross-Browser], [Rubric §30, Compliance, Privacy & Data Governance]. DownloadMyDataAsync (LegalAndDataCard.razor.cs:167-226) fetches the export once through UserService.ExportAsync(UserId, ...) (:172), then branches on ExternalLinks.InterceptsLinks (:181). On a native head it stages the bytes in a dedicated temp folder (ExportStagingFolderName = "mmca-exports", :38), purges every stale file there first (:187-195), writes the file and opens the OS share sheet through Share.ShareFileAsync (:197), toasting a warning if sharing is unavailable. The staged copy is the full personal-data export, so it is not deleted after the share: the comment explains that Android's share request returns when the intent launches, so a delete then would race the receiving app reading the file (:183-186); the folder is purged before each export and at app start by the MAUI host (:33-37). On the browser head it wraps the bytes in a DotNetStreamReference and calls a collocated JS module's saveFile (:205-210, module path :31).
  • Concept introduced (2), the file name is untrusted. [Rubric §11, Security]. ResolveExportFileName (:156-165) reduces the server's suggested name to a bare file name with Path.GetFileName, so it cannot steer the native staging write out of the temp directory, and falls back to user-data-{userId}.json under InvariantCulture when the name is empty, . or ...
  • Concept reinforced, the component-scoped lifetime token. [Rubric §18, UI Architecture]. Same shape as Profile: a private CancellationTokenSource (:43), a LifetimeToken property over _cts.LifetimeToken() (:66) so a call that resumes after disposal gets a cancelled token, and Dispose that cancels then disposes (:89-111). OperationCanceledException is swallowed in the load and in the download (:133-136, :212-215). The generic catch (Exception) (:216-220) is deliberate: this runs from an OnClick callback, which on the native heads is fatal to the host if anything escapes, so any failure of the staging write, the share sheet or the JS save becomes a Snackbar.DownloadDataFailed toast.
  • Walkthrough
    • Status lines (:74-86): TermsStatusText is null when the standing is unknown or the host configures no terms version, the "accepted on {date}" line when both AcceptedVersion and AcceptedOn are present (date formatted through ViewerTime.Format(acceptedOn, "d")), and "not accepted" otherwise. TermsOutdatedText appears only when an older version is on record and IsCurrent is false.
    • OnInitializedAsync (:114-137): resolves ILegalAcceptanceUIService with GetService and returns if absent, so a composition without the framework client (a component test rendering the profile alone) keeps the card working without the status line (comment :116-118). A failed read is silent, like the avatar read (:126-131).
    • OnAfterRenderAsync (:144-150): on first render resolves the viewer's time zone once JS is available and re-renders when it changed, so the acceptance date moves from the UTC fallback to the viewer's clock.
  • Why it's built this way: the standing and the export both act only on the caller, which is why the card takes a single UserId parameter (:51, [EditorRequired]) and reads the standing from the token-scoped framework client. The server side of the standing is LegalAcceptanceController; the export is served by UsersDataExportController.
  • Where it's used: rendered by Profile; LegalAndDataCard.razor.cs is also referenced from the MAUI host (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs, for the staging-folder purge at app start).

DeleteAvatarBlobInternalCommandHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.DeleteAvatarBlob · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommandHandler.cs:17 · Level 6 · class (sealed)

  • What it is: the handler for DeleteAvatarBlobInternalCommand: a thin sealed subclass of the shared DeleteBlobInternalCommandHandlerBase<TCommand> that only names the blob kind for its log lines (DeleteAvatarBlobInternalCommandHandler.cs:17-24).
  • Depends on: DeleteBlobInternalCommandHandlerBase<TCommand>, IFileStorageService; externals: Microsoft.Extensions.Logging.
  • Concept reinforced, idempotent cleanup on a not-found, extracted to a shared base. [Rubric §29, Resilience & Business Continuity]: the not-found-is-success behavior now lives once in DeleteBlobInternalCommandHandlerBase<TCommand>.HandleAsync, which treats ErrorType.NotFound from fileStorage.DeleteAsync as success rather than a failure, so a retried delete (at-least-once dispatch, or a blob already removed by a prior attempt) does not surface as one. This module's handler no longer implements that logic itself; it only supplies the label.
  • Walkthrough: the whole class body is a primary constructor forwarding fileStorage and logger to the base and one property override, BlobKind => "Avatar blob" (:17-24), which the base's [LoggerMessage] lines interpolate as {BlobKind} {BlobName} deleted / was already gone / could not be deleted.
  • Why it's built this way: DeleteSessionAssetBlobInternalCommandHandler in the Conference module needed the identical delete-and-tolerate-not-found flow, so the shared logic (including the three [LoggerMessage] methods) moved to MMCA.Common.Application.InternalCommands.DeleteBlobInternalCommandHandlerBase<TCommand>; each module now contributes only its BlobKind label instead of duplicating the handler.
  • Where it's used: registered as the handler for DeleteAvatarBlobInternalCommand through the module's DI registration.

DeleteAvatarBlobInternalCommandValidator

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.DeleteAvatarBlob · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommandValidator.cs:10 · Level 5 · class (sealed)

Profile

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI.Pages.Users.Profile · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor.cs:18 · Level 6 · class (partial)

  • What it is: the code-behind for the "My Profile" page at /profile: the signed-in user's self-service surface, namely set or remove the avatar, change the password (through the shared ChangePasswordCard), reach the signed-in-devices page, see the legal documents and download their data (the LegalAndDataCard sub-component), and delete the account. The class doc says the page manages the avatar and the delete; the password section is the shared card (Profile.razor.cs:14-17).
  • Depends on: IUserUIService, IAuthUIService (logout after delete only, Profile.razor.cs:217), IToastService, IMediaPickerService and PickedMedia, ChangePasswordCard and LegalAndDataCard (markup, Profile.razor:85, :109), plus Blazor's AuthenticationStateProvider, NavigationManager, InputFileChangeEventArgs, and MudBlazor's MudMessageBox (Profile.razor.cs:1-25). The localizer L is injected in the markup half (Profile.razor:4), which is also where the route and the [Authorize] attribute live (Profile.razor:1-2).
  • Concept introduced (1), the cross-head capability branch. [Rubric §22, Responsive & Cross-Browser] assesses whether one component genuinely serves different hosts. The same page runs in the Blazor web head and inside the MAUI hybrid, and the avatar affordance differs: the markup branches on MediaPicker.IsSupported (Profile.razor:47) to offer "choose photo" plus "take photo" buttons on a native head, and falls back to a hidden <InputFile accept="image/jpeg,image/png,image/webp"> behind a label-styled button on the web (Profile.razor:60-66). Both branches converge on one private method, UploadAvatarStreamAsync (Profile.razor.cs:158-169), so the upload contract is written once. That convergence is also why IUserUIService's upload method is typed on a raw Stream rather than on a framework-specific file type (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/IUserUIService.cs:47).
  • Concept introduced (2), the component-scoped CancellationTokenSource. [Rubric §18, UI Architecture]. The page holds private readonly CancellationTokenSource _cts = new() (Profile.razor.cs:30) and cancels plus disposes it through the standard disposable pattern (Profile.razor.cs:231-249). It no longer reads _cts.Token directly: every service call passes the LifetimeToken property (Profile.razor.cs:35, _cts.LifetimeToken()), because reading Token off a disposed source throws ObjectDisposedException; since Dispose cancels before it disposes, a load or handler that resumes after the user navigated away gets an already-cancelled token and stops through its OperationCanceledException path (comment at :32-34). Every async entry point then catches OperationCanceledException and deliberately does nothing (Profile.razor.cs:64-67, :107-110, :144-147, :186-189, :221-224), with the comment naming the two causes: component disposal and an InteractiveAuto render-mode transition. A user who navigated away must not be shown a red error toast for a request that was cancelled on their behalf; a genuine failure still toasts.
  • Concept reinforced, the Result-returning UI service. [Rubric §24, Forms, Validation & UX Safety]. Every call into IUserUIService returns a Result, and the page reads it with TryGetValue or IsSuccess rather than with a null check (Profile.razor.cs:58, :161, :176, :211). A failed read and a successful read of nothing are therefore different states, which is what lets the avatar load stay silent on failure while an avatar upload failure toasts.
  • Walkthrough
    • OnInitializedAsync (Profile.razor.cs:44-76): awaits AuthStateProvider.GetAuthenticationStateAsync() and reads the subject with the shared GetUserId() claims helper (:52), whose comment records that it accepts both the sub and the nameidentifier form and parses invariantly (:50-51). The page identifies its subject from the token, never from a route parameter, which is what makes it structurally incapable of acting on another account. Only when the claim parses does it fetch the avatar URL (:58-61), and a failed avatar read is deliberately silent (:56-57): the card falls back to its person icon rather than greeting the user with an error. _isLoading is cleared in finally (:72-75).
    • Client-side avatar guard (:27-28, :129-133): MaxAvatarBytes = 2 * 1024 * 1024, and the browser path rejects an oversized file with a warning toast before any bytes leave the machine. The doc comment says exactly what this is: a mirror of the server's BR-116a limit, not the enforcement point. The server re-checks in UsersController (UsersController.cs:92-98) and again in SetUserAvatarHandler.
    • Avatar paths: PickAvatarAsync (:82) and CaptureAvatarAsync (:86-87) both funnel through UploadPickedMediaAsync(pick, noMediaMessage) (:89-119), which disposes the PickedMedia with using (:94). When the picker returns null (media is null, :95-103) the behavior now depends on the caller: picking passes noMediaMessage: null and returns silently, but capturing passes L["Avatar.NoPhotoTaken"] and shows an info toast, because an empty capture may be a cancel or a camera that could not be opened (the platform picker swallows that failure), so the attendee is told either way (comment at :84-85). The browser path OnBrowserInputChangedAsync (:121-156) opens the stream with file.OpenReadStream(MaxAvatarBytes, LifetimeToken) (:138) and wraps it in await using so the read stream is released even on failure. UploadAvatarStreamAsync (:158-169) toasts on a failed result and only assigns _avatarUrl on success (:167-168).
    • RemoveAvatarAsync (:171-194): the failure branch of the result toasts Avatar.RemoveFailed (:183), so the user sees an explicit outcome for "did not work".
    • Password section (Profile.razor:78-86): the code-behind no longer carries any password state, validators, or SavePasswordAsync. The page keeps only the card frame and heading and drops in the shared ChangePasswordCard from MMCA.Common.UI, which owns the fields, the required, min-length and match validators, the ErrorSummary and the toasts (per the markup comment, Profile.razor:78-80). [Rubric §24, Forms, Validation & UX Safety]: that client-side validation is an ergonomics layer over, never instead of, the server rules that live in ChangePasswordRequestValidator and ChangePasswordHandler.
    • Signed-in devices (Profile.razor:88-104): a card whose only content is a link to RoutePaths.Sessions (:100). The comment above it records why there is no logic here (:88-89): the sessions page itself ships from MMCA.Common, so this card is only the way in from the profile.
    • Legal and your data (Profile.razor:106-109): <LegalAndDataCard UserId="_userId.Value" />, extracted into its own component so this code-behind stays a container within the line cap (markup comment, :106-108). See LegalAndDataCard.
    • DeleteAccountAsync (:196-229): guards on a known _userId (:198-202), shows the MudMessageBox (Profile.razor:129-139) and aborts unless the answer is exactly true (confirmed is not true, :204-206, so cancel and dismiss both abort), then deletes through LifetimeToken (:211), calls AuthService.LogoutAsync() (:217), toasts, and navigates to / with forceLoad: true (:219). The forced load matters: the account is gone, so the circuit and every piece of cached client state must be rebuilt from scratch rather than kept alive against a dead identity.
  • Why it's built this way: keeping avatar, password, sessions, legal and data, and deletion on one page mirrors the account-level scope of all five (each acts on the caller and only the caller), and every action ends in a toast with a localized message, so the page needs no bespoke error surface. [Rubric §21, Accessibility]: the markup carries an explicit aria-label on the MudAvatar (Profile.razor:33) because MudBlazor renders role="img", which the axe role-img-alt rule requires to carry an accessible name even in the fallback-icon state, and the comment above it says so (Profile.razor:30-32).
  • Where it's used: routed at /profile for any authenticated user in both the web and MAUI heads. Its server round trips land on the Identity avatar endpoints on UsersController, AuthController's PUT password, and UsersController's DELETE {userId}.
  • Caveats / not-in-source: the client-side password rules (minimum length, match) now live inside the shared ChangePasswordCard in MMCA.Common.UI, not in this page, so what they enforce is not determinable from these files.

UserList

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI.Pages.Users · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:25 · Level 6 · class (partial)

  • What it is: the code-behind for the organizer user list page at /users, a thin shell over the framework's shared UserAdminList<TUser> (ADR-116), which renders the roster, the mobile cards, and the administration actions (lock, unlock, change role) against the framework's Admin/Users endpoints. ADC supplies what stays its own: the route, the Organizer authorization, the BR-51 roster endpoint, the extra name and creation columns, the role vocabulary, and delete (UC-21).
  • Depends on: UserAdminList<TUser>, IUserUIService, UserListDTO, RoleNames, Result.
  • Concept introduced, splitting the roster read from the administration write. [Rubric §18, UI Architecture], [Rubric §7, Microservices Readiness]. The class doc's <remarks> (UserList.razor.cs:19-24) states the reasoning directly: the roster keeps reading through IUserUIService rather than the framework's administration read service, because ADC's /Users endpoint is the one that has always offered per-column filters and server-side sorting; the three account actions (lock, unlock, change role) go through the framework's client either way, since those are identical in every app. The class was previously its own DataGridListPageBase<UserListDTO> subclass with its own grid, mobile list, search, and delete wiring; that machinery moved into UserAdminList<TUser> and this class shrank to ComponentBase plus a viewer-clock hook and one data-fetch method.
  • Walkthrough
    • AssignableRoles (UserList.razor.cs:31-32): the same least-to-most-privileged role array UserDetail carries, kept here because the base's inline role editor needs it for this list.
    • ViewerTime and OnAfterRenderAsync (:36-50): the Created On column renders on the viewer's clock, so the page injects ViewerTimeZone (:37) and, on first render only, awaits ViewerTime.EnsureResolvedAsync() once JS is available and calls StateHasChanged() when the zone changed (:46-49). Until then dates show on the UTC fallback.
    • FetchPageAsync (:65-86): the one data method ADC still owns. It maps the base's generic filter bag onto the four filters IUserUIService.GetPagedAsync takes (BR-51). The free-text search box arrives under UserAdminList<UserListDTO>.SearchFilterKey and wins over an explicit Email column filter (:73-74), because both affordances resolve to the one email parameter.
    • FilterValue (:91-94): reads one filter's value out of the bag by key; the operator half of the tuple is unused, because every filter here is treated as a value match or a search term, not a MudBlazor comparison operator.
  • Why it's built this way: nameof(UserListDTO.Email) and its three siblings, rather than string literals, keep the filter keys tied to the DTO's own property names, so a rename is a compile error rather than a silently dead filter.
  • Where it's used: routed as the organizer user-management page in the ADC web and MAUI UI; the server side it calls for the roster is UsersController, whose list endpoint is gated on the UsersRead capability in IdentityPermissions (UsersController.cs:139); the lock/unlock/role actions it delegates to the base call UsersAdminController. RoleList reuses UserList.razor.cs as a reference for the same list-shell pattern applied to roles.

EmailConfirmationController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/EmailConfirmationController.cs:29 · Level 6 · class (sealed)

  • What it is: the two-action email-confirmation surface, POST send-email-confirmation (schedules a confirmation link) and POST confirm-email (redeems it). It is now a body-light subclass: both actions are inherited from EmailConfirmationControllerBase<TSendCommand, TConfirmCommand> (EmailConfirmationController.cs:29-33), and this class supplies only the two command factories.
  • Depends on: EmailConfirmationControllerBase<TSendCommand, TConfirmCommand> (base, MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EmailConfirmationControllerBase.cs:43), ICommandHandler<in TCommand, TResult> closed over SendEmailConfirmationCommand and ConfirmEmailCommand; externals: Asp.Versioning, ASP.NET Core MVC.
  • Concept reinforced, the derived controller supplies command factories to a shared base. [Rubric §11, Security], [Rubric §30, Compliance, Privacy & Data Governance], [Rubric §15, Best Practices & Code Quality]. The enumeration-safe posture is unchanged but now lives in the base: SendEmailConfirmationAsync always answers 202 Accepted on a well-formed request, whether or not the address holds an account and whether or not it is already confirmed (EmailConfirmationControllerBase.cs:71-96, Accepted() at :95), so a caller cannot enumerate registered addresses, the same posture PasswordResetController takes on forgot-password. Both base actions carry [AllowAnonymous], [Idempotent], and [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] (:79-81, :107-109), the same "auth-ip" fixed window that guards login, register, and password reset (ADR-019). The commands stay app-side, as in the password-recovery base, and the base reads them back only through ICommandWithRequest<TRequest> (:46-47).
  • Walkthrough
    • The primary constructor (EmailConfirmationController.cs:29-33) takes the two closed command handlers and forwards them to the base. Route Auth and version 1.0 stay on this class, repeated because the base leaves them to the derived controller (base remarks, EmailConfirmationControllerBase.cs:38).
    • CreateSendCommand (:37) and CreateConfirmCommand (:40): one-line => new(request) factories building SendEmailConfirmationCommand and ConfirmEmailCommand, the abstract members the base declares (EmailConfirmationControllerBase.cs:61, :69).
    • The inherited ConfirmEmailAsync (EmailConfirmationControllerBase.cs:114) dispatches the confirm command and answers 204 on success ([ProducesResponseType(StatusCodes.Status204NoContent)], :110). Its doc comment states the transport reason the link works the way it does (:99-101): the address and the token travel in the URI fragment, so the confirmation page reads them client-side and posts them here, and neither ever reaches a server log or a Referer header.
  • Why it's built this way: the actions were previously written in this file; once the framework shipped a controller base for the pair, ADC kept only what is app-specific (the command records), the same split PasswordResetController uses. Both inherited actions end in the same result.IsFailure ? HandleFailure(result.Errors) : ... tail every controller in the module uses, so a validation or not-found failure becomes the shared ProblemDetails shape.
  • Where it's used: registration schedules a SendEmailConfirmationCommand from AuthController after a successful POST register (ADR-116); the confirmation page then calls confirm-email with the token from the emailed link. The architecture suite's anonymous-endpoint test enumerates both actions explicitly.

UsersAdminController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersAdminController.cs:25 · Level 6 · class (sealed)

  • What it is: ADC's Admin/Users endpoint surface (list, get, lock, unlock, set role). It declares no new behavior beyond two attribute-only overrides: every action is inherited from UsersAdminControllerBase<TUserDto>.
  • Depends on: UsersAdminControllerBase<TUserDto> (base, closed over UserAdminDTO), IUserAdministrationService<TUserDto>, NonIdempotentAttribute.
  • Concept reinforced, the sibling controller pattern applied to administration. [Rubric §15, Best Practices & Code Quality]. Like OAuthController and UsersDataExportController, the class exists to bind a shared base to an app-specific service, here UserAdministrationService through IUserAdministrationService<TUserDto>. Both overrides carry [NonIdempotent(...)] (UsersAdminController.cs:32, :41), and each message explains why replaying a cached response would be wrong rather than merely unnecessary: locking and unlocking are already idempotent in the domain (a second lock keeps the original instant, :32), so caching a response risks answering a later unlock-then-lock cycle from the cache and reporting a state the account is no longer in.
  • Walkthrough: two overrides, LockAsync (:23-24) and UnlockAsync (:32-33), each a one-line base.___Async(...) call whose only reason to exist is the attribute the architecture suite requires on every POST action.
  • Why it's built this way: the override bodies add nothing; the class is a place to attach an idempotency declaration the base's generic action cannot carry per-app, since the reasoning text is specific to what "already idempotent in the domain" means for this aggregate.
  • Where it's used: mounted by the Identity service host, routed through the Gateway under Admin/Users; driven by UserDetail and UserList through the framework's IUserAdminUIService<TUserDto> client. AdminRolesController is the equivalent surface for roles.

OAuthController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:21 · Level 7 · class (sealed)

  • What it is: ADC's external social-login endpoint surface (the class doc names Google, GitHub, and Apple). It is a body-less class: every action is inherited from the shared OAuthControllerBase, and this file exists to supply the route, the version, and the concrete dependencies.
  • Depends on: OAuthControllerBase, IAuthenticationService (aliased at OAuthController.cs:6 to disambiguate from the ASP.NET Core type of the same name), ICacheService, IConfiguration, IDistributedLock (ADR-108).
  • Concept introduced, the derived-controller-as-configuration pattern. [Rubric §15, Best Practices & Code Quality] assesses whether a shared workflow is inherited rather than copied, and [Rubric §9, API & Contract Design] covers the route surface. The whole class is a primary constructor forwarding four dependencies to a base (OAuthController.cs:21-25), terminated with a semicolon: there is no body at all. The base owns the four-step flow, which the class doc states in order (OAuthController.cs:10-17): challenge, provider callback, complete, then a single-use-code exchange, with tokens never riding the redirect URL (ADR-036). That last step is the security-relevant one, because a token in a redirect URL lands in browser history and referrer headers ([Rubric §26, Front-End Security], [Rubric §11, Security]).
  • Walkthrough: three attributes and four constructor parameters.
    • [ApiController], [Route("auth/oauth")], [ApiVersion("1.0")] (OAuthController.cs:18-20). The class doc records the non-obvious reason these are repeated here rather than inherited (OAuthController.cs:14-15): routing and versioning attributes are not reliably inherited from the base, so omitting them would leave the endpoints unroutable.
    • IAuthenticationService (OAuthController.cs:22) resolves to Identity's own AuthenticationService, which is what makes the base's provider-agnostic flow create or link an ADC User. ICacheService (:23) backs the single-use exchange code. IConfiguration (:24) supplies the provider and redirect settings.
    • IDistributedLock (:25) is passed to the base's four-argument constructor (OAuthControllerBase.cs:50-54). ExchangeAsync then reads and burns the single-use code inside TryAcquireAsync on lock:{cacheKey} (OAuthControllerBase.cs:253-264), so two replicas redeeming the same code at the same instant cannot both mint the token pair; a contended redeem is answered as an invalid code, never a second success (:250-252). A subclass keeping the three-argument constructor would get no lock and a read-then-burn that is single-use only per replica (OAuthControllerBase.cs:35-40). The class doc records the same intent (OAuthController.cs:16).
  • Why it's built this way: external OAuth is an ADC-only feature (Store is local-credential only), but the protocol is app-agnostic, so the flow was hoisted into MMCA.Common.API and each app contributes only its route prefix and its authentication service. Pairing this controller with AddExternalAuthProviders in the service host is what completes the wiring, and the class doc says so (OAuthController.cs:13-14).
  • Where it's used: mounted by the Identity service host and fronted by the Gateway; the browser and MAUI clients drive it, and the email-verified guard on the linking step is answered by HttpContextExternalLoginEmailVerifier.
  • Caveats / not-in-source: the provider registrations, the scopes, and the Google email_verified claim mapping live in the Identity service host's Program.cs, not in this file, so which of the three named providers a given environment actually offers is not determinable from here.

UsersController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:30 · Level 9 · class (sealed)

  • What it is: the /users REST surface: the organizer user list, account delete, and the three avatar endpoints for the signed-in user. Five actions, each a thin adapter over one handler.
  • Depends on: ApiControllerBase, ICommandHandler<in TCommand, TResult> and IQueryHandler<in TQuery, TResult> closed over five use cases (UsersController.cs:31-35), ICurrentUserService (:36), HasPermissionAttribute, IdentityPermissions, IdempotentAttribute, UserListDTO, UserAvatarDTO, PagedCollectionResult<T>; externals: ASP.NET Core MVC (IFormFile, [RequestSizeLimit], [Range]).
  • Concept introduced, the controller as a translator between HTTP and the handler pipeline, and the two shapes of authorization. [Rubric §9, API & Contract Design], [Rubric §11, Security], [Rubric §5, Vertical Slice] (assesses whether each endpoint routes to its own use case rather than into a shared service). Every action follows the same four lines: read the caller from ICurrentUserService, build the command or query record, await handler.HandleAsync(...), then result.IsFailure ? HandleFailure(result.Errors) : Ok(...). The controller holds no business logic, which is why the decorator pipeline (logging, caching, validation, transaction) applies uniformly. The authorization split is the interesting part:
    • Declarative, for a capability-shaped rule: the list endpoint carries [HasPermission(IdentityPermissions.UsersRead)] (UsersController.cs:139, the constant is "identity:users:read" at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:13), the permission-based check of ADR-020. A caller without that capability never reaches the handler.
    • In-handler, for an ownership-shaped rule: delete passes the caller's id and role into the command (UsersController.cs:175) so DeleteUserHandler can apply owner-or-Organizer and, importantly, return 404 rather than 403 for a stranger's id, which avoids leaking whether that account exists (ADR-033).
  • Walkthrough: class-level [Authorize] (UsersController.cs:29) makes every action authenticated by default, and each me/... action re-checks currentUserService.UserId is null and returns Unauthorized() for the "authenticated but no usable id" case.
    • Two size constants, and the reason there are two. MaxAvatarBytes = 2 * 1024 * 1024 (:39) is the BR-116a image ceiling; MaxAvatarRequestBytes = MaxAvatarBytes + 64 * 1024 (:48) is the transport-level cap for the whole multipart body. The doc explains the trap (:41-47): without headroom for the boundary lines and part headers, a file at exactly the image limit is rejected by Kestrel with a bare 413 before the friendly Avatar.InvalidUpload check can run. [Rubric §24, Forms, Validation & UX Safety]: the difference between a helpful error and a raw protocol failure is 64 KB of slack.
    • GET me/avatar (:51-66), resolving the subject from the token rather than from a route parameter.
    • POST me/avatar (:79-116) is the richest action, and it carries three attributes worth reading together. [Idempotent] (:80) routes the request through the framework's Idempotency-Key filter (ADR-017); the action doc explains the judgement call (:72-77), which is that the upload replaces the caller's avatar rather than appending one, so replaying the stored URL for a repeated key is both safe and useful (it skips a second re-encode of identical bytes and stops a flaky mobile upload from looking like a failure the user has to redo). [RequestSizeLimit(MaxAvatarRequestBytes)] (:81) rejects an oversized body at the pipeline before any of it is buffered; the inline guard then re-checks null, zero-length, and over-limit and returns a validation error (:92-98), so the limit is enforced twice for two different failure modes. The stream is copied into a right-sized MemoryStream (:100-107), with both await using scopes carrying ConfigureAwait(false), and the byte array is handed to SetUserAvatarCommand (:109-111). The action doc also records what the handler then does (:69-71): sniff the real format (jpeg/png/webp), re-encode to 256x256 JPEG, return the public URL. Trusting a declared content type here would be an upload vulnerability.
    • DELETE me/avatar (:119-134), documented idempotent, returning 204.
    • GET users (:138-159): eight [FromQuery] parameters with [Range(1, int.MaxValue)] on both paging values (:146-147), so a pageNumber=0 is a model-binding 400 rather than a handler concern.
    • DELETE {userId} (:162-181), the ownership-checked action, declaring 403 and 404 in its ProducesResponseType set (:164-165).
  • Why it's built this way: injecting five separate closed handler interfaces rather than one "user service" is what keeps each endpoint on its own vertical slice and makes the decorator pipeline the single place where cross-cutting behavior lives (ADR-014). The consistent HandleFailure(result.Errors) tail means every failure becomes a ProblemDetails with the same shape, mapped once in ApiControllerBase. The GDPR export, which used to be a sixth action here, now lives on UsersDataExportController and still serves the same /Users/{userId}/export path.
  • Where it's used: mounted by the Identity service host and routed through the Gateway; consumed by UserService on the client side (and therefore by UserList and Profile), and warmed at startup by SelfHttpWarmupTask, which replays users?pageNumber=1&pageSize=10 against this host's own Kestrel endpoint (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:35) and treats the resulting 401 as the expected outcome (:45), because an unauthenticated self-request hits this class's [Authorize] plus [HasPermission] pair.

AdminRolesController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AdminRolesController.cs:36 · Level 11 · class (sealed)

  • What it is: ADC's role-administration endpoint surface. Like OAuthController, it is a body-less class: every action comes from RolesAdminControllerBase, and this file exists only to supply the concrete dependencies.
  • Depends on: RolesAdminControllerBase (base), IRoleAdministrationService, ICurrentUserService.
  • Concept reinforced, the derived-controller-as-configuration pattern. [Rubric §15, Best Practices & Code Quality]. The whole class is a primary constructor forwarding two dependencies to the base, terminated with a semicolon (AdminRolesController.cs:36-37): no route, no version attribute, no action body, the same shape OAuthController and UsersDataExportController use for a workflow that is app-agnostic once the app's own service is supplied.
  • Walkthrough: one line, public sealed class AdminRolesController(IRoleAdministrationService administration, ICurrentUserService currentUser) : RolesAdminControllerBase(administration, currentUser); (:36-37).
  • Why it's built this way: role administration (list roles, view members, grant/revoke) is identical in every app that shares the framework's role vocabulary, so the base owns the whole surface and each app contributes only the service that reads and writes its own User rows.
  • Where it's used: mounted by the Identity service host's Program.cs and routed through the Gateway; UsersAdminController is the equivalent surface for individual accounts rather than roles.

LegalAcceptanceController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/LegalAcceptanceController.cs:24 · Level 11 · class (sealed)

  • What it is: the signed-in user's Terms of Service acceptance endpoint, GET and POST /Users/me/legal-acceptance. Like OAuthController it is body-less: the actions, their authorization and the version rules all come from LegalAcceptanceControllerBase, and this subclass supplies only the route (LegalAcceptanceController.cs:10-15).
  • Depends on: LegalAcceptanceControllerBase (base, MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:45), ILegalAcceptanceService, ICurrentUserService, LegalAcceptanceOptions via IOptions, and the LegalAcceptanceDTO it returns.
  • Concept reinforced, the derived-controller-as-configuration pattern, with the policy in the base. [Rubric §15, Best Practices & Code Quality], [Rubric §9, API & Contract Design]. The primary constructor forwards three dependencies and ends in a semicolon (LegalAcceptanceController.cs:24-28). What matters is where the rules live: the base, not ADC's service. An acceptance is refused unless it names the configured current version (LegalAcceptancePolicy.EnsureAcceptsCurrentVersion, LegalAcceptanceControllerBase.cs:110-115), the stored value is the configured one and never the client's string (:117-120), and every answer is re-derived against that version (LegalAcceptancePolicy.Normalize, :83, :125). With no version configured, GET answers IsCurrent = true without calling the service (:65-69), so a client never blocks a user on a host that has not opted in. The base is [Authorize] (:44) and the POST is [Idempotent] (:94).
  • Walkthrough: three class attributes and one constructor.
    • [ApiController], [Route("Users")], [ApiVersion("1.0")] (LegalAcceptanceController.cs:21-23). The route is the literal Users, not [controller], because the base fixes the action template to me/legal-acceptance and the published path must stay /Users/me/legal-acceptance, which the Gateway already forwards under its /Users/** route (:16-19). Same trick as UsersDataExportController and PasswordResetController.
    • The three constructor parameters (:25-27) are passed straight to the base. The acceptance store itself is ADC's LegalAcceptanceService.
  • Why it's built this way: the version rules are framework policy shared by every consumer, so keeping them in the base means a consumer's service only stores and reads acceptance rows (opt-in under ADR-116).
  • Where it's used: called by the framework's ILegalAcceptanceUIService, which LegalAndDataCard reads to render the accepted-terms line; backed by LegalAcceptanceService.

UsersDataExportController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:26 · Level 11 · class (sealed)

  • What it is: the data-subject access and portability endpoint, GET /Users/{userId}/export. Like OAuthController it declares no action of its own: the action, its authorization, its feature gate, and the file-download response all come from DataExportControllerBase<TQuery>, and this class supplies the route and one query factory.
  • Depends on: DataExportControllerBase<TQuery> (base), IQueryHandler<in TQuery, TResult> closed over ExportUserDataQuery, UserDataExportDTO, ICurrentUserService, Result, and PrivacyFeatures.
  • Concept introduced, the abstract base with an app-owned query factory. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether the subject-rights obligations are implemented rather than described, and [Rubric §15, Best Practices & Code Quality] covers the factoring. The export workflow is identical in every app, but the query record is not: each app's ExportUserDataQuery lives in its own Application assembly, so a concrete shared controller could not construct a type it cannot see. The base's remarks state that reasoning explicitly (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:45-50), and the resolution is an abstract CreateQuery (:119-122) implemented here as a one-liner (UsersDataExportController.cs:32-35).
  • Concept introduced (2), defence in depth on a privacy endpoint. [Rubric §11, Security]. Three independent gates sit in front of one document. The base carries [Authorize] (DataExportControllerBase.cs:59), so an anonymous caller never arrives; it carries [FeatureGate(PrivacyFeatures.DataExport)] (:58), so the whole surface stays absent until a host turns the flag on (ADR-031); and the handler independently enforces owner-or-Organizer, which is why the class doc here can say "the handler enforces it" (UsersDataExportController.cs:16-17) and the base can say the endpoint cannot leak another subject's data even if it were mounted without an [Authorize] of its own (DataExportControllerBase.cs:51-56). The ADC privilege predicate is one line in ExportUserDataHandler (.../UseCases/ExportUserData/ExportUserDataHandler.cs:38).
  • Walkthrough: three attributes and two members.
    • [ApiController], [Route("Users")], [ApiVersion("1.0")] (UsersDataExportController.cs:23-25). The route is the literal Users, not [controller], and the <remarks> say why (:19-22): the base fixes the action template to {userId}/export (DataExportControllerBase.cs:79), so a subclass routed at Users keeps serving the published /Users/{userId}/export path even though the class is named UsersDataExportController. This is the same trick PasswordResetController uses on /Auth.
    • The primary constructor (:26-29) takes the closed export handler and ICurrentUserService and forwards both to the base.
    • CreateQuery(userId, currentUserId, currentUserRole) (:32-35): builds ADC's ExportUserDataQuery from the route id and the authenticated caller. The base's constraint where TQuery : IUserOwnedRequest (DataExportControllerBase.cs:64) is what lets the shared pipeline reason about ownership on an app-owned record.
    • The inherited action, ExportAsync (DataExportControllerBase.cs:84-114): reject an unusable caller id with Privacy.Unauthorized (:86-90), build the query and dispatch (:92-94), then serialize the package with JsonSerializer.SerializeToUtf8Bytes(export, JsonSerializerOptions.Web) and return File(...) (:107-109). The comment explains the choice (:103-106): Ok(export) would content-negotiate and render inline, and the point of this endpoint is a document the subject saves and keeps. The download name is built from the package's own GeneratedOn under InvariantCulture (:133-134), so the file name and the document always agree and a saved file sorts the same in every locale.
  • Why it's built this way: ADR-076 is the decision this implements, and the split (route and query here, everything else in the base) follows the AuthControllerBase precedent the base cites (DataExportControllerBase.cs:40-44): the app owns its URL space, the framework owns the workflow.
  • Where it's used: handled by ExportUserDataHandler; called by an authenticated subject exporting their own data, or by an Organizer acting on a request, through the Gateway's existing /Users route.
  • Caveats / not-in-source: whether the endpoint is reachable in a given environment depends on the PrivacyFeatures.DataExport flag value, which lives in host configuration and not in this file.

PasswordResetController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:28 · Level 16 · class (sealed)

  • What it is: the anonymous password-recovery surface, POST /Auth/forgot-password and POST /Auth/reset-password. Like OAuthController it declares no actions of its own: the two endpoints are inherited, and this class supplies the route, the version, and two one-line command factories.
  • Depends on: PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> (base, at MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43), ICommandHandler<in TCommand, TResult> closed over ForgotPasswordCommand and ResetPasswordCommand, Result, and the shared ForgotPasswordRequest / ResetPasswordRequest contracts; externals: Asp.Versioning, ASP.NET Core MVC.
  • Concept introduced (1), the sibling controller as a way around single inheritance. [Rubric §9, API & Contract Design] assesses whether the URL surface stays coherent as capabilities are added, and [Rubric §15, Best Practices & Code Quality] covers the cost of getting there. AuthController already spends its one base class on the shared account controller, so recovery could not be more actions on that type. The resolution is a second controller with [Route("Auth")] written as a literal rather than [controller] (PasswordResetController.cs:26), which lands both endpoints on the same /Auth prefix the Gateway already fronts. The <remarks> state that motivation directly (:19-24): both endpoints ride the existing gateway route with no gateway change. Contrast this with UserClaimsController's [Route("[controller]")], where the class name is the resource.
  • Concept introduced (2), anonymous by necessity, and the response posture that follows. [Rubric §11, Security] and [Rubric §30, Compliance, Privacy & Data Governance]. A user who has lost a credential cannot present one, so requiring authentication here would be circular; the base marks both actions [AllowAnonymous] (PasswordResetAuthControllerBase.cs:77, :101) and the framework's anonymous-endpoint architecture test lists them explicitly rather than letting them slip through unnoticed. Because the endpoints are open, the response shapes are chosen to reveal nothing: forgot-password always answers 202 Accepted (:79, :92) whether or not the address holds an account, so a caller cannot enumerate registered addresses, and every reset rejection collapses into one 401 (:105) so an invalid token and an unknown address are indistinguishable. Both carry [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] (:78, :102), the same "auth-ip" fixed window that guards login and register (ADR-019), and both carry [Idempotent] (:76, :100) so a retried mobile submit does not send a second reset email or burn a second token (ADR-017).
  • Walkthrough: three class attributes and four members.
    • [ApiController], [Route("Auth")], [ApiVersion("1.0")] (PasswordResetController.cs:25-27), the same routing-and-versioning triple every ADC controller repeats because those attributes are not reliably inherited.
    • The primary constructor (:28-33) takes the two closed command handlers and forwards them to the base, which exposes them as ForgotPasswordHandler and ResetPasswordHandler (PasswordResetAuthControllerBase.cs:50, :53).
    • CreateForgotPasswordCommand(ForgotPasswordRequest request) => new(request) (PasswordResetController.cs:36) and CreateResetPasswordCommand(ResetPasswordRequest request) => new(request) (:39), the two abstract factories the base declares (PasswordResetAuthControllerBase.cs:61, :69). This is the same split AuthController uses for change-password: the workflow is shared, the command records are not, because ADC marks its reset command ICacheInvalidating and Store does not (ResetPasswordCommand.cs:16-19).
    • The inherited actions themselves: ForgotPasswordAsync (PasswordResetAuthControllerBase.cs:82-93) dispatches the app command and returns Accepted(); ResetPasswordAsync (:107-118) dispatches and returns NoContent(). Both end in the same result.IsFailure ? HandleFailure(result.Errors) : ... tail as every other controller in the module.
  • Why it's built this way: the reset credential is a cache record keyed by the address rather than columns on the user row, which is the decision ADR-091 records: it costs no migration in any consumer, adds nothing to the hottest entity in the system, needs no sweeper because the cache enforces expiry itself, and reuses the substrate ADR-029 already chose for login lockout. That choice is invisible from this file, which is the point: the controller only names the two commands.
  • Where it's used: handled by ForgotPasswordHandler and ResetPasswordHandler; driven by unauthenticated browser and MAUI clients through the Gateway's existing /Auth route.
  • Caveats / not-in-source: nothing about token generation, its TTL, the attempt cap, or the email send is visible here; all of it lives in the two handlers and the cache-backed store behind them.

AuthController

MMCA.ADC.Identity.API · MMCA.ADC.Identity.API.Controllers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:34 · Level 17 · class (sealed, partial)

  • What it is: the /auth endpoint surface: login, register, refresh, revoke, change password, and get/set the stored culture and theme preferences. Most of it is inherited; this class overrides two actions and supplies the two command factories.
  • Depends on: UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (which itself extends AuthControllerBase), IAuthenticationService, ICurrentUserService, ChangePasswordCommand, ChangePreferencesCommand, GetUserPreferencesQuery, AuthenticationResponse, RegisterRequest, LoginRequest, UserPreferencesResponse, IInternalCommandScheduler, SendEmailConfirmationCommand; externals: ASP.NET Core rate limiting ([EnableRateLimiting]).
  • Concept introduced, the generic controller base parameterized by the app's command types. [Rubric §15, Best Practices & Code Quality], [Rubric §1, SOLID], [Rubric §11, Security]. The base owns the four token actions plus PUT password (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:87), PUT preferences (:112), and GET preferences (:138). It cannot own the command records, because ADC marks its change-password command ICacheInvalidating with a cache prefix built from ADC's own User type while Store does not; the shared handler base's remarks record that reason (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:19-24). The resolution is two abstract factory methods (UserAccountAuthControllerBase.cs:67, :77), which this class implements as one-liners (AuthController.cs:125-133): the shared workflow stays shared, and each app keeps its own command semantics. PasswordResetController is the same pattern applied to the recovery pair.
  • Concept introduced (2), scheduling a side effect after commit instead of sending it inline (ADR-116). [Rubric §6, CQRS & Event-Driven], [Rubric §29, Resilience & Business Continuity]. RegisterAsync no longer stops at 201 Created: once the registration itself succeeds, it schedules a SendEmailConfirmationCommand through IInternalCommandScheduler (AuthController.cs:92-96), except for synthetic load-test accounts (below). The comment above the call (:81-91) states the reasoning in full: the registration transaction has already committed by that point, so the row is written and signalled immediately and the internal-command processor picks it up on its next tick; a mail outage or a crash in between defers the send rather than losing it. It also names why the trigger lives in the controller rather than in a domain event: ADC raises no UserRegistered domain event (only the cross-service integration event UserRegistered), and AuthenticationService already sits at the dependency ceiling, so the controller is the one place that both knows a registration just succeeded and is free to take a new dependency. A scheduling failure never fails the 201: the account exists and the user is signed in either way, and they can request a fresh link from the confirmation page; the failure is logged at Error through LogConfirmationScheduleFailed (:98-101, :106-107) because a recurring one means nobody is being asked to confirm anything. The synthetic-account skip (:74-79): when SyntheticAccounts.IsSynthetic(request.Email) is true, RegisterAsync returns the same 201 Created immediately and schedules nothing, because the conference-day load test's accounts sit under an undeliverable reserved domain and a confirmation email to each would only spend the SMTP relay's daily quota and reputation (comment at :74-75).
  • Walkthrough: primary constructor forwarding seven dependencies to the base (AuthController.cs:34-48, two more than before: IInternalCommandScheduler and ILogger<AuthController>), then four members.
    • RegisterAsync (AuthController.cs:53-104) is a genuine override rather than a pass-through. It passes the base's ClientIpAddress and ClientUserAgent into AuthenticationService.RegisterAsync (:64-66; the two properties are computed from HttpContext on the base). Success returns 201 Created explicitly (:103, or :78 on the synthetic-account path), after the confirmation-scheduling step above.
    • LoginAsync (AuthController.cs:120-123) overrides only to re-declare attributes, then calls base.LoginAsync. The reason is the attribute set: [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] (:116) and the documented 429. The doc comment (:109-113) states the threat model precisely: the per-email lockout of BR-212 cannot stop one source spraying a single common password across many different emails, so a per-IP fixed window sits on top of it (ADR-019, ADR-029). Both anonymous endpoints, register and login, carry the same policy.
    • CreateChangePasswordCommand and CreateChangePreferencesCommand (AuthController.cs:125-133), the two factory implementations that bind ADC's command records to the base's workflow.
  • Why it's built this way: [Route("[controller]")] makes the prefix /auth, which is what the Gateway's route map fronts, and [ApiVersion("1.0")] keeps it on the header-based versioning scheme (ADR-046). The class is now partial (:33) because the [LoggerMessage] source generator needs a partial method declaration for LogConfirmationScheduleFailed (:106-107). Both anonymous actions are marked [AllowAnonymous] with the fully qualified attribute name because the file's using set does not import the authorization namespace.
  • Where it's used: the entry point for every authenticated ADC client. The Blazor and MAUI clients call login/register/refresh through the Gateway; the Profile page uses the inherited PUT auth/password and the preferences pair; ChangePasswordRequestValidator guards the password action through the Validating decorator; a successful register schedules the confirmation link handled by EmailConfirmationController. Its anonymous sibling on the same /Auth prefix is PasswordResetController.

UserDetail

MMCA.ADC.Identity.UI · MMCA.ADC.Identity.UI.Pages.Users.UserDetail · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserDetail/UserDetail.razor.cs:23 · Level 6 · class (sealed)

  • What it is: the code-behind for the organizer account-detail page: lock or unlock an account and change its role, one user at a time.
  • Depends on: IUserAdminUIService<TUserDto> (closed over UserAdminDTO), IAppDialogService, IToastService, RoleNames; externals: Blazor's AuthenticationStateProvider, BreadcrumbItem.
  • Concept introduced, the self-action guard on an administration page. [Rubric §11, Security], [Rubric §24, Forms, Validation & UX Safety]. CanAdminister (UserDetail.razor.cs:58-61) compares the account on screen against the caller's own id, read from the authentication state during OnInitializedAsync (:72-73), and the two admin actions are enabled only when it is true. An organizer locking or role-changing their own account mid-session is the trap this closes: the page never lets the operator submit that request in the first place.
  • Walkthrough
    • OnInitializedAsync (:64-74): builds the breadcrumb trail and reads _currentUserId from AuthStateProvider.GetAuthenticationStateAsync().
    • OnParametersSetAsync (:81): calls LoadAsync on every parameter set rather than only on first render, so navigating from one account to another inside the same page instance reloads the account named in the URL, the same pattern UserList's sibling pages rely on.
    • LoadAsync (:83-92): fetches the account through UserAdminService.GetAsync(Id, LifetimeToken) and seeds _selectedRole from the loaded user, falling back to RoleNames.Attendee when the fetch failed.
    • ToggleLockAsync (:94-134): confirms through Dialogs.ConfirmAsync with a lock- or unlock-worded message built from the account's own email, then calls UserAdminService.LockAsync or UnlockAsync, toasts the outcome, and reloads on success. _isBusy is set before the call and cleared in finally (:114, :130-133); the lock, unlock and role calls all pass LifetimeToken.
    • ChangeRoleAsync (:136-171): a no-op when the selected role already matches the account's role (:138-141, ordinal comparison), otherwise the same confirm-then-call-then-toast-then-reload shape as ToggleLockAsync, through UserAdminService.SetRoleAsync.
    • Dispose (:174-179): cancels and disposes the page's CancellationTokenSource and suppresses finalization.
  • Why it's built this way: every service call takes LifetimeToken (:36, _cts.LifetimeToken()) rather than _cts.Token, because reading Token off a disposed source throws ObjectDisposedException; Dispose cancels before it disposes, so a load or handler that resumes after the user navigated away gets an already-cancelled token and stops through its OperationCanceledException path. AssignableRoles (:28-29) is [RoleNames.Attendee, RoleNames.ContentEditor, RoleNames.Organizer], least to most privileged, taken from the domain's own role vocabulary so the menu never drifts from the roles the server actually accepts. Both mutating actions confirm before acting, the same destructive-action discipline UserList's delete uses.
  • Where it's used: routed from UserList as the per-account detail page in the ADC web and MAUI UI; the account it edits is read and written through IUserAdminUIService<UserAdminDTO>, the framework's shared administration client (ADR-116), which calls the API surface UsersAdminController exposes.

SpeakerLinkedToUserHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Speakers.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Speakers/IntegrationEventHandlers/SpeakerLinkedToUserHandler.cs:32 · Level 8 · class (sealed, partial)

  • What it is: the Identity-side subscriber for Conference's SpeakerLinkedToUser integration event. It sets User.LinkedSpeakerId, the Identity half of the bidirectional User-to-Speaker link (BR-209).
  • Depends on: ScopedIntegrationEventHandlerBase<TIntegrationEvent> (base, which implements IIntegrationEventHandler<in TIntegrationEvent>); SpeakerLinkedToUser from MMCA.ADC.Conference.Shared; IUnitOfWork and IRepository<TEntity, TIdentifierType>; User; IServiceScopeFactory and ILogger with the [LoggerMessage] source generator.
  • Concept introduced, the integration-event handler as a base-class subclass. [Rubric §6, CQRS & Event-Driven] and [Rubric §7, Microservices Readiness]. Two structural facts separate this from a domain-event handler. First, integration-event handlers are registered as singletons, so they cannot constructor-inject a scoped service such as IUnitOfWork. Every handler therefore needs a DI scope per delivery. Rather than repeat that preamble, this class extends ScopedIntegrationEventHandlerBase<SpeakerLinkedToUser> (SpeakerLinkedToUserHandler.cs:32-35), which opens the async scope, hands the subclass that scope's IServiceProvider, and disposes it (MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:45-63). The subclass body is only its own resolutions plus its own logic, and the class doc says exactly that (SpeakerLinkedToUserHandler.cs:19-23). Second, this is a cross-service subscription: the event type comes from Conference's Shared assembly, travels through Conference's outbox and the MassTransit broker, and lands here (ADR-003). The class doc records what it replaced, a direct call from Conference into an Identity service interface (:13-18). Inverting that into an event is what allowed the two modules to become separate processes with separate databases, and it is why the doc can state that this handler is the only place User.LinkedSpeakerId changes in response to a Conference-side change.
  • Walkthrough (primary constructor :27-30, HandleScopedAsync :33-58)
    1. Scope-resolved services (:38-39), IUnitOfWork from the provided IServiceProvider, then GetRepository<User, UserIdentifierType>() (the mutating repository, because this path writes). The null guard on the event itself lives in the base (ScopedIntegrationEventHandlerBase.cs:47).
    2. Load (:41), GetByIdAsync(integrationEvent.UserId, ...). A missing user logs a warning and returns (:42-46, template at :81) rather than throwing: retrying cannot conjure an account that no longer exists.
    3. Idempotency check (:48-52), when LinkedSpeakerId already equals the event's SpeakerId, return without writing. The comment says why this matters: delivery is at-least-once, so a redelivery must be a no-op rather than a second write.
    4. Apply and persist (:54-55), user.LinkSpeaker(...) then SaveChangesAsync, followed by an information-level log (:57, template at :84).
    5. Failure policy, inherited. The base wraps the body in catch (Exception ex) when (ex is not OperationCanceledException && LogAndRethrow(...)) (ScopedIntegrationEventHandlerBase.cs:57-62): the filter always returns false, so the exception keeps propagating with its original stack and the throw; inside the block is unreachable by construction. Cancellation short-circuits the filter and propagates without an error log, because host shutdown is not a delivery failure. This class overrides only the log line, LogHandlerFailure (:78-79), so the error names both sides of the link (:87-88) instead of the base's generic message.
  • Why it's built this way: [Rubric §29, Resilience & Business Continuity]. The override's <remarks> (:66-77) record the failure this design corrects. The handler used to swallow every exception, so one transient database fault lost the BR-209 back-link permanently: the delivery was already acknowledged, so nothing retried, and the user kept a null LinkedSpeakerId while Conference believed the speaker was linked, with only a log line to show for it. Propagating hands the decision to the delivery mechanism instead: the inbox row stays unprocessed, MassTransit redelivers, and a message that keeps failing moves to the error queue where an operator can see it. Retrying is only safe because step 3 exists, which is the base's stated contract: subclasses must be idempotent (ScopedIntegrationEventHandlerBase.cs:33).
  • Where it's used: auto-registered by the Application-layer Scrutor scan and driven by the broker consumer for SpeakerLinkedToUser; the full outbox to broker to consumer round trip is exercised by the cross-service Testcontainers test tier.

SpeakerUnlinkedFromUserHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Speakers.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Speakers/IntegrationEventHandlers/SpeakerUnlinkedFromUserHandler.cs:27 · Level 8 · class (sealed, partial)

  • What it is: the mirror of SpeakerLinkedToUserHandler. It subscribes to Conference's SpeakerUnlinkedFromUser and clears User.LinkedSpeakerId, but only when the link still points at the speaker the event names.
  • Depends on: the same set as its sibling: ScopedIntegrationEventHandlerBase<TIntegrationEvent>, SpeakerUnlinkedFromUser, IUnitOfWork, User, IServiceScopeFactory, and [LoggerMessage] logging.
  • Concept introduced, guarding against out-of-order delivery. [Rubric §29, Resilience & Business Continuity] and [Rubric §8, Data Architecture]. The scope preamble, the idempotency habit, and the log-and-propagate envelope are the ones taught at SpeakerLinkedToUserHandler; what is genuinely new here is a third guard. Linked and Unlinked arrive on separate queues with no ordering guarantee, so an Unlinked event for a previous speaker can land after the user has already been re-pointed at a new one. Clearing the column on that message would silently drop a link that is still valid. The handler therefore compares before clearing (SpeakerUnlinkedFromUserHandler.cs:54-61) and, on a mismatch, logs a warning naming both speaker ids (:59, message template at :93-94) and leaves the link in place. That is the general lesson for any compensating message: an event that undoes something must verify it is undoing the thing it was written for.
  • Walkthrough (HandleScopedAsync :33-67): resolve the unit of work and the mutating repository from the scope the base opened (:38-39); load, with a warning-and-return on a missing user (:41-46); the already-cleared short circuit when LinkedSpeakerId has no value, which is the idempotent-redelivery case (:48-52); the stale-event guard above (:54-61); then user.UnlinkSpeaker() plus SaveChangesAsync and an information log (:63-66). Failure handling is the inherited exception filter with only the log line overridden (:87-88), carrying the same <remarks> rationale as its sibling (:75-86).
  • Why it's built this way: three separate early returns (not found, already cleared, different speaker) each map to a distinct real situation and each gets its own message and log level, so an operator reading the log can tell "nothing to do" apart from "something is off". Only the last is a warning about suspicious ordering; the "already cleared" case logs nothing at all.
  • Where it's used: auto-registered by the Application-layer Scrutor scan; triggered by Conference's unlink command and by the cascade cleanup that runs when a Speaker is soft-deleted (BR-70), as the class doc records (:10-18).

UserAdministrationService

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.Administration · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Administration/UserAdministrationService.cs:38 · Level 8 · class (sealed, partial)

  • What it is: ADC's implementation of the framework's IUserAdministrationService<UserAdminDTO>, the service UsersAdminController calls: list/search the roster, get one account, lock/unlock, and change role.
  • Depends on: IUnitOfWork, IQueryableExecutor, IRefreshSessionStore, TimeProvider, User, UserRole, PagingMath, PaginationMetadata / PagedCollectionResult<T>, and [LoggerMessage] logging.
  • Concept introduced, revoking live sessions on a privilege-relevant write. [Rubric §11, Security], [Rubric §29, Resilience & Business Continuity]. Both SetLockedAsync (:627-659) and SetRolesAsync (:662-712) call RevokeSessionsAsync (:762-779) after a successful lock or a genuine role change, but only then: locking a session that is already locked, or reassigning a role to the value it already holds, returns early and revokes nothing (:696-701 for the role case). The comment at :705-706 states why revocation matters for role: the role travels in the access token, so leaving live sessions alone would keep the old capability set working until every issued token expired on its own. Locking revokes unconditionally because the account must stop working immediately, not merely stop being able to mint new tokens.
  • Walkthrough
    • MaxPageSize = 100 (:566): restated rather than referenced from the controller's own constant, because the Application layer does not depend on the API package; the comment notes a drift here can only make this the tighter of the two caps.
    • ListAsync (:569-608): clamps paging through PagingMath.Clamp (:576), narrows with ApplyFilters, counts, then orders .OrderBy(u => u.Email).ThenBy(u => u.Id) before Skip/Take (:588-590). The Id tie-break is called out in the surrounding comment (:586-587): without it, rows sharing an email sort key can repeat or vanish across pages, because OFFSET/FETCH has no stable row order to page over otherwise. The projection to UserAdminDTO happens in the query (:593-602).
    • ApplyFilters (:739-760): the search term matches the whole email address by parsing it through Email.Create first (:744-748), because User.Email is a converter-backed value object the provider can only compare against another converted Email; an unparseable term simply matches no address, the same answer the database gives for an address nobody holds. The two name predicates use Contains and stay independent of whether the email half parsed.
    • SetRolesAsync (:662-712): rejects anything but exactly one role with a User.Role.ExactlyOne validation error (:667-673, ADC accounts hold exactly one role), parses it through UserRole.FromString, then defers to User.ChangeRole for the domain rule.
    • GetAsync / ToDto (:611-624, :714-723): a not-found Result when the id misses, otherwise the same DTO shape ListAsync projects, assembled here from a tracked-or-not-tracked load instead of a query projection.
    • RevokeSessionsAsync (:762-779): loads the account's unrevoked sessions, revokes each with RefreshSession.ReasonSignedOut, and saves once; an empty session set returns immediately without a write.
  • Why it's built this way: the two [LoggerMessage] partials (:781-785) log every lock/unlock and every role change at Information with the acting context, so an audit trail of administrative account changes exists without a bespoke audit table.
  • Where it's used: registered as IUserAdministrationService<UserAdminDTO> in the Application layer's DependencyInjection and resolved by UsersAdminController; its DTO shape is the one UserDetail and UserList render.

UserDTOMapper

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.DTOs · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/DTOs/UserDTOMapper.cs:14 · Level 8 · class (sealed, partial)

  • What it is: the compile-time mapper from the User aggregate to UserDTO, for the single and the collection case.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (implemented), User, UserDTO, the Email value object, and Mapperly (Riok.Mapperly.Abstractions).
  • Concept introduced, source-generated mapping with a hand-written converter hook. [Rubric §9, API & Contract Design] assesses whether the wire shape is decided deliberately, and [Rubric §15, Best Practices & Code Quality] covers the generator choice. [Mapper] on a partial class (UserDTOMapper.cs:13-15) tells Mapperly to generate the body of every partial method declaration at build time (ADR-001). There is no reflection and no runtime configuration: the generated assignment list is ordinary C# you can step through, and a property that cannot be mapped is a build diagnostic rather than a silent null at runtime. The value-object problem is solved by convention rather than by attribute. User.Email is an Email while UserDTO.Email is a string, and Mapperly resolves that by finding a method in the class whose signature converts one to the other: the private static string EmailToString(Email email) => email.Value (:28). Adding that method is the entire configuration.
  • Walkthrough
    • MapToDTO(User entity) (:18): declared partial with no body; the generator emits the property-by-property assignment.
    • MapToDTOs(IReadOnlyCollection<User>) (:21-25): hand-written, not generated. It null-guards with ArgumentNullException.ThrowIfNull (:23) and returns [.. entityCollection.Select(MapToDTO)] (:24), a collection expression over the generated single-item map, so there is exactly one mapping definition to keep correct.
    • EmailToString (:28): the converter hook described above.
  • Why it's built this way: [Rubric §30, Compliance, Privacy & Data Governance]. The doc comment (:9-12) states the real purpose of the type: it excludes the credential material from the projection. That exclusion is not enforced by an attribute, it follows from UserDTO simply not declaring those members (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDTO.cs:8-24 declares exactly Id, Email, FirstName, LastName, Role, all required and init-only). The DTO is the allowlist, and because Mapperly maps by name at compile time, adding a sensitive property to the aggregate cannot leak it: someone would have to add it to the DTO on purpose.
  • Where it's used: registered as IEntityDTOMapper<User, UserDTO, UserIdentifierType> by the Application-layer Scrutor scan and resolved wherever the module returns a full user representation. Note that the organizer list endpoint does not go through it: GetUsersHandler projects straight to UserListDTO in the database, so the sensitive columns are never read at all.
  • Caveats / not-in-source: the doc comment still names RefreshToken among the excluded fields (:11), a property User no longer has; sessions moved to the framework's RefreshSessions table (User.cs:15-16).

ChangePasswordCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ChangePassword · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:15 · Level 8 · record (sealed)

  • What it is: the command for a self-service password change. It pairs the target UserId with the ChangePasswordRequest body, opts into automatic request validation, and evicts the user cache on success.
  • Depends on: ChangePasswordRequest; the UserIdentifierType alias; User (only for typeof(User).FullName); and three framework markers, ICommandWithRequest<out TRequest>, ICacheInvalidating, and IUserScopedCommand<out TRequest>.
  • Concept introduced, three markers that each buy exactly one pipeline behavior. [Rubric §6, CQRS & Event-Driven] (a command is a named intention carrying exactly what the write needs) and [Rubric §2, Design Patterns]. The record declares no members beyond CachePrefix; everything else it does is expressed by which interfaces it lists (ChangePasswordCommand.cs:16).
    • ICommandWithRequest<ChangePasswordRequest> opts the command into automatic validation: the framework registers a CommandRequestValidator<TCommand, TRequest> that delegates to the registered IValidator<ChangePasswordRequest> through FluentValidation's SetValidator, with TryAdd semantics so an explicit command-level validator still wins (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/ICommandWithRequest.cs:5-11).
    • ICacheInvalidating gives the caching decorator a prefix to evict, $"{typeof(User).FullName}:" (ChangePasswordCommand.cs:19). Deriving it from the type rather than from a string literal keeps it in lockstep with the key the user cache actually uses: rename or move User and the prefix follows. ResetPasswordCommand carries the identical prefix for the same reason, and its doc says so explicitly (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:10-12, :18).
    • IUserScopedCommand<ChangePasswordRequest> is the view the shared handler base reads the command through, and it changes no pipeline behavior on its own. Its doc comment records why the two are separate rather than merged: the automatic-validation opt-in is a per-application decision, and ADC and Store agree on it for password change but disagree for preferences (MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:6-11).
  • Walkthrough: a two-parameter positional record (UserIdentifierType UserId, ChangePasswordRequest Request) (ChangePasswordCommand.cs:15) plus the single computed CachePrefix property (:17-18). UserId comes from the authenticated principal, Request from the body.
  • Why it's built this way: splitting "who" (the token) from "what" (the body) makes it structurally impossible for a request to target another account, and the shared handler base stays generic in TCommand precisely so each application can keep its own marker set on the record while sharing one workflow.
  • Where it's used: constructed by AuthController's CreateChangePasswordCommand override (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:126-128) and handled by ChangePasswordHandler.

ChangePreferencesCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ChangePreferences · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:14 · Level 8 · record (sealed)

  • What it is: the command that persists one user's culture and theme preferences, the write side of ADR-027 / ADR-028. It pairs the target UserId with the partial ChangePreferencesRequest and evicts the user cache so a preference change cannot be masked by a stale cached read.
  • Depends on: ChangePreferencesRequest; the UserIdentifierType alias (= int in this module); User (only for typeof(User).FullName); ICacheInvalidating and IUserScopedCommand<out TRequest>.
  • Concept reinforced, marker-driven pipeline behavior (introduced at ChangePasswordCommand). [Rubric §12, Performance & Scalability] assesses caching with correct invalidation, and [Rubric §6, CQRS & Event-Driven] assesses whether a command is a named intention carrying exactly what the write needs. The instructive detail is what this record does not implement: the declaration lists only ICacheInvalidating and IUserScopedCommand<ChangePreferencesRequest> (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:15), with no ICommandWithRequest<out TRequest>, so the framework's reflection loop never registers a CommandRequestValidator<TCommand, TRequest> bridge for it (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:121-135). IUserScopedCommand<out TRequest>'s own <remarks> records why the two markers are separate rather than merged: the automatic-validation opt-in is a per-application decision, and ADC and Store agree on it for the password change but disagree for preferences (MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:6-11). Declining the bridge is not the same as declining validation: ADC hand-writes ChangePreferencesCommandValidator directly over this command type, which AddValidatorsFromAssembly(moduleAssembly) picks up (DependencyInjection.ModuleScanning.cs:117) and the validating decorator runs. The aggregate still owns the last word regardless, because User.UpdatePreferences re-checks both values through UserInvariants (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:372-385).
  • Walkthrough: a two-parameter positional record (UserIdentifierType UserId, ChangePreferencesRequest Request) (ChangePreferencesCommand.cs:14), plus one computed member, CachePrefix => $"{typeof(User).FullName}:" (:18). Deriving the prefix from the type rather than from a string literal keeps it in lockstep with the key the user cache actually uses: rename or move User and the prefix follows.
  • Why it's built this way: the command record deliberately stays application-side rather than moving into the framework alongside its handler, because ADC marks it ICacheInvalidating with a prefix built from its own User type and Store does not, so a single shared record could not preserve both behaviors (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:16-20). Expressing eviction as an interface the command implements keeps cache management a decorator concern instead of handler boilerplate.
  • Where it's used: constructed by AuthController's CreateChangePreferencesCommand override (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:131-133) for the profile page and the app-bar culture and theme switchers; handled by ChangePreferencesHandler.

DeleteUserCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.DeleteUser · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:19 · Level 8 · record (sealed)

  • What it is: the command that soft-deletes and erases a user account (UC-21). It carries the account being deleted and the authenticated caller, because deletion is one of the few operations where the caller may legitimately not be the subject.
  • Depends on: the UserIdentifierType alias; User (for typeof(User).FullName); ICacheInvalidating; ITransactional; IUserOwnedRequest.
  • Concept introduced, the owner-or-privileged-role request shape. [Rubric §11, Security] assesses whether authorization decisions are made from data the caller cannot forge, and [Rubric §1, SOLID] covers why this is an interface rather than a naming convention. IUserOwnedRequest extends IUserScopedRequest with CurrentUserId and a nullable CurrentUserRole (MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserOwnedRequest.cs:8-15), which is exactly the triple the shared UserOwnershipRule needs to answer "may this caller act on that account". Both extra values are filled from the token by the controller, never from the body, so a client cannot claim a role it was not issued. Only two ADC use cases wear this shape, deletion and data export, and they are precisely the two that must let an Organizer act on someone else's row.
  • Concept introduced, marking erasure as one atomic write. [Rubric §29, Resilience & Business Continuity] and [Rubric §30, Compliance, Privacy & Data Governance]. The record now also implements ITransactional (DeleteUserCommand.cs:22), and its <remarks> (:8-15) explains why: the soft-delete flag, the anonymized personal data, the UserDeleted outbox row, and the internal-command row that schedules the avatar blob delete (ADR-114) all need to commit together or not at all. DeleteUserHandler's tail enrolls that internal-command row before the save, and an enrolled row is exactly what a transaction makes atomic with the change it belongs to; without ITransactional the schedule could flush ahead of a half-finished erasure.
  • Walkthrough: a three-parameter positional record, UserId, CurrentUserId, CurrentUserRole (DeleteUserCommand.cs:11-14), plus the computed CachePrefix => $"{typeof(User).FullName}:" (:17). CurrentUserRole is string? because a token may carry no role claim at all, and the null case must resolve to "no privilege" rather than to an exception.
  • Why it's built this way: modelling the caller as part of the command, rather than reaching for an ambient HttpContext inside the handler, is what keeps the handler testable without a web host and keeps the Application layer free of ASP.NET types ([Rubric §3, Clean Architecture], [Rubric §14, Testability]).
  • Where it's used: constructed by UsersController's DeleteAsync from currentUserService.UserId and currentUserService.Role (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:166-181); handled by DeleteUserHandler.

ResetPasswordCommand

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ResetPassword · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:15 · Level 8 · record (sealed)

  • What it is: the second half of the forgot-password vertical (ADR-091). It carries the address, the single-use token from the reset email, and the new password, and it evicts the user cache because the credential the cached aggregate holds has just changed.
  • Depends on: ResetPasswordRequest (from MMCA.Common.Shared.Auth); ICommandWithRequest<out TRequest> and ICacheInvalidating; User, for typeof(User).FullName only.
  • Concept reinforced, the same two markers with the opposite validation route. Put this record next to ChangePreferencesCommand above and the marker system explains itself. Both are one-line records with the same CachePrefix; the difference is that this one does implement ICommandWithRequest<ResetPasswordRequest> (ResetPasswordCommand.cs:16), which opts it into automatic CommandRequestValidator<TCommand, TRequest> registration, so the validating decorator runs ResetPasswordRequestValidator against the embedded payload before the handler ever sees the command. [Rubric §11, Security]: that validator includes StrongPasswordRules<T> over NewPassword (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:23), with the stated reason that a reset must not become a way around the complexity policy that registration and change-password enforce (:8-10). [Rubric §6, CQRS & Event-Driven]: notice there is no UserId parameter. The caller is anonymous at this point, so the account is not named by the request at all: it is recovered from the redeemed token inside the handler, which is what stops the endpoint from being usable to set an arbitrary account's password.
  • Walkthrough: a single-parameter positional record (ResetPasswordRequest Request) (ResetPasswordCommand.cs:15) implementing both markers (:15), plus CachePrefix => $"{typeof(User).FullName}:" (:18). The payload itself is a readonly record struct of three strings, Email, Token, NewPassword (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ResetPasswordRequest.cs:9-12), whose doc comment marks NewPassword as transmitted over TLS and never logged (:8).
  • Why it's built this way: the record stays application-side while its workflow lives in the framework, for the reason the base states in its own <remarks>: the shared handler reads the command only through ICommandWithRequest<ResetPasswordRequest>, so each application keeps its own record and its own cache-invalidation decision (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:24-27). That is the same hoist boundary the change-password vertical uses, and the summary on this record says so explicitly (ResetPasswordCommand.cs:10-12).
  • Where it's used: built by PasswordResetController's CreateResetPasswordCommand override, a single expression-bodied new(request) (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:39), behind POST /Auth/reset-password; handled by ResetPasswordHandler.

ChangePasswordHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ChangePassword · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:24 · Level 9 · class (sealed)

  • What it is: ADC's change-password handler. The class body is empty: the whole workflow lives in the framework base ChangePasswordHandlerBase<TUser, TCommand>, and this type exists to bind the generic parameters and to keep the name.
  • Depends on: ChangePasswordHandlerBase<TUser, TCommand> (base), IUnitOfWork, IPasswordHasher, ILogger<T>, IRefreshSessionStore, ILoginProtectionService, TimeProvider, User, and ChangePasswordCommand.
  • Concept introduced, the name-preserving thin subclass. [Rubric §15, Best Practices & Code Quality] assesses de-duplication across applications, and [Rubric §9, API & Contract Design] covers why the class name survives the move. ADC and Store carried line-identical copies of this handler, so the workflow was hoisted into MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:14-17). What could not be hoisted is the error payload: every failure the framework returns carries a source, the base defaults it to GetType().Name through a virtual HandlerName (ChangePasswordHandlerBase.cs:57-62), and clients match on the string ChangePasswordHandler. Keeping an empty subclass under the original name makes the hoist invisible on the wire, and the <remarks> says so outright (ChangePasswordHandler.cs:14-23). It is a small pattern with a large consequence: a refactor that would otherwise be a breaking API change becomes a no-op for consumers. The same shape recurs at ChangePreferencesHandler, GetUserPreferencesHandler, and (for the controller layer) at PasswordResetController and UsersDataExportController.
  • Concept introduced, revoking live sessions on a credential change (SEC-Common-02). [Rubric §11, Security]. The constructor takes IRefreshSessionStore and a TimeProvider, forwarded straight to the base (ChangePasswordHandler.cs:24-39), so a successful change revokes every live refresh session on the account (ChangePasswordHandlerBase.cs:110-114), not only the credential the caller just replaced. Without them a stolen refresh chain would outlive the very password change meant to remediate it, which is the one recovery step a compromised account has. ResetPasswordHandler carries the identical addition for the same reason.
  • Concept introduced, throttling the current-password check (ADR-029). [Rubric §11, Security]. The constructor also takes ILoginProtectionService, forwarded to the base (ChangePasswordHandler.cs:29, :36). A wrong current password counts against the account through the same failed-attempt counter and exponential lockout that guard sign-in, so a caller holding a session cannot use the endpoint as an unthrottled password oracle (ChangePasswordHandlerBase.cs:34-40). The counter is keyed password-change:{userId} rather than by email, because the authenticated user exposes no address and a separate key keeps a change-password lockout from locking the owner out of sign-in (ChangePasswordHandlerBase.cs:37-39).
  • Walkthrough: a primary constructor taking IUnitOfWork, IPasswordHasher, ILogger<ChangePasswordHandler>, IRefreshSessionStore, ILoginProtectionService and TimeProvider, forwarding all six to the base (ChangePasswordHandler.cs:24-37), with an empty body (:38-39). The inherited workflow (ChangePasswordHandlerBase.cs:65-120) is: null-guard the command (:69); load the user through the mutating repository and return Error.NotFound tagged with HandlerName when absent (:71-76); reject an account with no stored hash or salt (an external-OAuth account) with the same Error.Unauthorized("Auth.InvalidCurrentPassword", ...) (:82-86); return the lockout failure if the password-change key is locked (:88-93); verify the supplied current password with passwordHasher.VerifyPassword(command.Request.CurrentPassword, user.PasswordHash, user.PasswordSalt) and, on a mismatch, increment the failed-attempt counter and return Error.Unauthorized("Auth.InvalidCurrentPassword", ...) (:95-100); reset the counter on a match (:102); hash the new password into a fresh hash and salt pair (:104); call user.ChangePassword(newHash, newSalt) and, only when that succeeds, save, revoke every refresh session and log (:105-117); return the aggregate's Result unchanged (:119).
  • Why it's built this way: [Rubric §11, Security]. Note the division of responsibility the base encodes. Proving knowledge of the current password is a cryptographic operation, so it happens where the stored hash and salt are in hand, not in a request validator (a validator can only check that a value is present). The domain then applies its own invariants on the new credential material, and nothing is persisted unless both gates pass. The generic constraint where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IPasswordChangeableUser (ChangePasswordHandlerBase.cs:49) is what lets the base call ChangePassword on an application's aggregate without knowing the concrete type, and the hashing scheme itself is ADR-032.
  • Where it's used: resolved as ICommandHandler<ChangePasswordCommand, Result> and injected into AuthController (AuthController.cs:37), which exposes it through the base's PUT /auth/password action; the Profile page is the client.

ChangePreferencesCommandValidator

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ChangePreferences · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommandValidator.cs:18 · Level 9 · class (sealed)

  • What it is: the FluentValidation validator for ChangePreferencesCommand. It rejects an unsupported culture or an unknown theme at the edge of the pipeline, before a transaction opens, while treating a null field as "leave this preference unchanged".
  • Depends on: FluentValidation's AbstractValidator<T>; ChangePreferencesCommand; SupportedCultures.IsSupported (MMCA.Common/Source/Core/MMCA.Common.Shared/Globalization/SupportedCultures.cs:35-37); CommonInvariants.LightTheme / .DarkTheme (MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:16, :19).
  • Concept introduced, validating a command directly instead of through the request bridge. [Rubric §6, CQRS & Event-Driven] assesses where input rejection belongs in a command pipeline, and [Rubric §24, Forms, Validation & UX Safety] covers partial updates. Most ADC commands get their validator for free: they implement ICommandWithRequest<out TRequest>, and the framework registers a CommandRequestValidator<TCommand, TRequest> bridge that forwards to the payload's validator (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:121-135). ChangePreferencesCommand deliberately does not wear that marker, so this validator is written against the command type itself and is picked up by the plain AddValidatorsFromAssembly(moduleAssembly) scan a few lines earlier (DependencyInjection.ModuleScanning.cs:117). Both routes land in the same place: ValidatingCommandDecorator<TCommand, TResult> resolves IEnumerable<IValidator<TCommand>> and runs every one it finds, sequentially, short-circuiting into a failure result before the transactional decorator is reached (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:32-37, :62-91). The framework's own architecture fitness rule counts either route as coverage and fails a data-carrying command that has neither (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Cqrs/ArchitectureRules.CommandValidators.cs:8-13, :23-30).
  • Concept introduced, the two-layer guard and why it is not a duplicated rule. [Rubric §4, DDD] assesses whether the aggregate remains the authority on its own invariants. The <remarks> here is explicit that the accepted sets are the same ones UserInvariants.EnsurePreferredCultureIsValid and EnsurePreferredThemeIsValid enforce on the aggregate, that this only moves the rejection in front of the transaction, and that the domain keeps its own guard for callers that never pass through the pipeline (ChangePreferencesCommandValidator.cs:11-17). The aggregate side is User.UpdatePreferences, which combines both checks with Result.Combine and refuses to mutate on failure (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:372-385). Read together, the validator gives a caller a 400 with a field-level error code instead of an opaque handler failure, and the domain still cannot be talked into an invalid state by a code path that skips the decorator (a seeder, a test, another module).
  • Walkthrough
    • RuleFor(x => x.Request).NotNull() (:22-25), with message "Preferences are required." and error code User.Preferences.Required. Every rule here carries an explicit WithErrorCode, which is what lets the UI bind the failure to a specific control rather than showing a banner.
    • Culture (:27-31): Must(SupportedCultures.IsSupported) gated by .When(x => x.Request is not null && x.Request.Culture is not null). The When clause is the partial-update contract in code: a request that omits the culture is not validated for culture at all, because omitting it means "keep what is stored". SupportedCultures.IsSupported matches case-insensitively against the framework allowlist All, which is ["en-US", "es"] today (SupportedCultures.cs:18, :35-37), so adding a locale is a one-line change there and no change here (ADR-027).
    • Theme (:33-37): the same When shape over the private helper IsKnownTheme (:43-45), an OrdinalIgnoreCase comparison against CommonInvariants.LightTheme ("light") and CommonInvariants.DarkTheme ("dark") (ADR-028). Case-insensitive on purpose: the theme value round-trips through a client-side toggle and a cookie, and casing is not part of the meaning.
  • Why it's built this way: [Rubric §12, Performance & Scalability] and [Rubric §15, Best Practices & Code Quality]. Rejecting bad input before the transactional decorator means an invalid preference costs no database transaction and no cache eviction. Reading the accepted values from the shared constants rather than re-typing "light" and "dark", and from SupportedCultures.All rather than a local array, is what keeps this validator from becoming a second, drifting definition of what is valid; the guard is duplicated in placement, not in content.
  • Where it's used: never referenced by name. It is discovered by the module-registration scan (DependencyInjection.ModuleScanning.cs:117) and injected into ValidatingCommandDecorator<TCommand, TResult> as one element of IEnumerable<IValidator<ChangePreferencesCommand>> around ChangePreferencesHandler.
  • Caveats / not-in-source: the NotNull rule on Request and the When guards both test x.Request is not null, even though ChangePreferencesRequest arrives from a deserialized body where the framework's nullable annotations do not bind; the defensive shape is what makes the validator safe against a body of null.

ChangePreferencesHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ChangePreferences · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:17 · Level 9 · class (sealed)

  • What it is: ADC's change-preferences handler, an empty subclass. The whole workflow lives in ChangePreferencesHandlerBase<TUser, TCommand>; this type exists to bind the generic parameters and to keep the name.
  • Depends on: ChangePreferencesHandlerBase<TUser, TCommand> (base), IUnitOfWork, ILogger<T>, User, and ChangePreferencesCommand.
  • Concept reinforced, the name-preserving thin subclass (introduced at ChangePasswordHandler); the <remarks> here carries the same rationale, that the class name is kept so the source reported on every error stays ChangePreferencesHandler, which clients match on (ChangePreferencesHandler.cs:12-16). The behavior worth learning is the null-coalescing merge inside the base. [Rubric §27, Internationalization] assesses whether a locale choice survives a session, and [Rubric §24, Forms, Validation & UX Safety] covers partial updates. The merge is one expression (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53-55): user.UpdatePreferences(command.Request.Culture ?? user.PreferredCulture, command.Request.Theme ?? user.PreferredTheme). A request carrying only a culture re-supplies the stored theme, and the reverse, which is precisely why the app-bar language switcher cannot wipe the user's dark-mode choice. It is also the same "null means unchanged" contract ChangePreferencesCommandValidator encodes with its When clauses one stage earlier.
  • Walkthrough: a primary constructor taking IUnitOfWork and ILogger<ChangePreferencesHandler> and forwarding both to the base (ChangePreferencesHandler.cs:17-20), with an empty body (:21-22). The inherited workflow (ChangePreferencesHandlerBase.cs:40-63): a null guard on the command (:44); load through the mutating repository, unitOfWork.GetRepository<TUser, UserIdentifierType>(), returning Error.NotFound.WithSource(HandlerName).WithTarget(typeof(TUser).Name) when absent (:46-51); the merge above (:53-55); then, only when the returned Result is a success, SaveChangesAsync plus the handler's own source-generated PreferencesChanged log (:56-60, declared at :65-66); return the result unchanged (:62). Value checking is not the handler's job: User.UpdatePreferences combines the culture allowlist and the light/dark rule and hands back a failure the handler simply propagates.
  • Why it's built this way: guarding both the save and the log behind IsSuccess means a rejected culture or theme produces a clean failure (a 400 at the edge) with no write and, just as importantly, no misleading "preferences changed" log line for an operator to chase later. The HandlerName indirection (ChangePreferencesHandlerBase.cs:37, GetType().Name) is what makes the empty subclass sufficient: the base reports the runtime type name, so the hoist is invisible on the wire.
  • Where it's used: resolved as ICommandHandler<ChangePreferencesCommand, Result> and injected into AuthController (AuthController.cs:38) as PUT /Auth/preferences; wrapped by the decorator pipeline, whose caching decorator performs the eviction declared on ChangePreferencesCommand.
  • Caveats / not-in-source: UpdatePreferences raises no domain event (User.cs:372-385), so a preference change writes the row and publishes nothing to the outbox.

DeleteUserHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.DeleteUser · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:29 · Level 9 · class (sealed)

  • What it is: ADC's account-deletion handler (UC-21). Unlike the two thin siblings it is not empty: it inherits the shared erasure workflow from DeleteUserHandlerBase<TUser, TCommand> and supplies the two genuinely ADC-specific pieces, the privileged role and an erasure tail that announces the deletion cross-service and cleans up the avatar blob.
  • Depends on: DeleteUserHandlerBase<TUser, TCommand> (base), IUnitOfWork, IInternalCommandScheduler, ICacheService (taken only to forward to the base, :32, :34), TimeProvider, UserRole, UserDeleted (the Identity.Shared integration event), SetUserAvatarHandler (for its TryGetBlobName helper), DeleteAvatarBlobInternalCommand, and Result.
  • Concept introduced (1), erasure as a fixed workflow with application-specific hooks. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether a deletion request actually destroys personal data. The base (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:80-163) runs a fixed order: check ownership through UserOwnershipRule.CheckOwnership using the application's HasDeletePrivilege answer (:82-92); load the user, Error.NotFound when absent (:94-99); soft-delete (:114-119); run the application's tail (:121-126); anonymize (:128-133); save (:135); write the shared soft-deleted marker (:137-149); then run whatever post-commit actions the tail enqueued (:151-154) and log the erasure (:156). The two-step delete-then-anonymize is ADR-005's resolution of a real tension: the row must survive because other bounded contexts hold scalar UserId references and the audit trail depends on it, but the personal data must not survive, because the privacy promise is erasure. One detail in the base rewards a second read (:109-115): it dispatches through IErasableUser erasable = user; rather than calling user.Delete() directly, because member lookup on a type parameter prefers its class constraint, and ADC's User hides the base Delete() with public new Result Delete() (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:485). Interface dispatch is what guarantees the aggregate's own version, the one that raises the in-process UserDeleted domain event (User.cs:487-493), actually runs.
  • Concept introduced (2), raising a cross-service integration event from inside the erasure. [Rubric §6, CQRS & Event-Driven] and [Rubric §30]. Personal data this account published into another service has to travel: Engagement holds a DisplayName snapshot on the leaderboard opt-in, Engagement is its own process with its own database, and the in-process domain event User.Delete() raises never reaches it. So the override calls user.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow())) (DeleteUserHandler.cs:59) on the aggregate, before the save. The comment (:52-57) states the invariant this buys: the outbox row is written by the very SaveChangesAsync that commits the erasure, so the fact and its announcement cannot come apart, whereas publishing after the commit would leave a crash window in which the account is gone and the published name is not. The event payload is deliberately just the id and a timestamp, because carrying a name or email would publish the very data the erasure exists to remove onto a broker that persists messages (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:17-21); its own doc also spells out that it stays separate from the same-named Identity domain event, which Engagement never sees (:11-16).
  • Concept introduced (3), scheduling the blob delete as a durable internal command instead of a post-commit action. [Rubric §29, Resilience & Business Continuity] and [Rubric §30, Compliance, Privacy & Data Governance]. The hook signature still takes the base's ICollection<Func<CancellationToken, Task>> afterCommit parameter (DeleteUserHandler.cs:43-47), but no longer uses it: earlier the avatar delete was queued into that best-effort post-commit list, which only ran once the process survived past the save. Now, when there is a blob, the hook calls ScheduleAvatarBlobDeletionAsync (:68-70), which enrolls a DeleteAvatarBlobInternalCommand row through internalCommandScheduler.ScheduleAsync (:78-80). Because this hook runs inside the erasure transaction, before SaveChangesAsync (DeleteUserHandlerBase.cs:118-139), the scheduled row commits with the anonymized aggregate or rolls back with it (ADR-114), which is exactly why DeleteUserCommand is now marked ITransactional (DeleteUserCommand.cs:22). An erasure that persists can therefore never lose the instruction to delete the photo, unlike the best-effort post-commit call it replaces, which silently dropped the deletion whenever storage was unreachable or the host died mid-tail; the framework's internal-command processor now picks the row up on its next poll and retries it until storage answers. If the scheduling call itself fails, ScheduleAvatarBlobDeletionAsync returns Result.Failure(scheduled.Errors) (:85) rather than success, which fails the whole erasure: a partial erasure the caller was told succeeded is worse than a retryable failure.
  • Walkthrough
    • Constructor (:29-35): five dependencies, of which unitOfWork, cacheService and logger are forwarded straight to DeleteUserHandlerBase<User, DeleteUserCommand> (:35); only internalCommandScheduler and timeProvider are used by this type's own code. There is no logger field and no [LoggerMessage] partial here: the one warning this path can emit belongs to the base.
    • HasDeletePrivilege (:38-40): UserRole.IsOrganizer(currentUserRole), whose implementation is an OrdinalIgnoreCase comparison (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:76), with a <remarks> noting it is case-insensitive because a role claim may carry any casing. Store's equivalent answers with its Admin role, which the base's own hook documentation names alongside ADC's (DeleteUserHandlerBase.cs:165-168); that one line is the entire difference in the authorization model between the two applications.
    • OnAfterSoftDeleteAsync (:43-74): it first captures the avatar blob name before anonymization clears the URL (:49-51), reusing SetUserAvatarHandler.TryGetBlobName (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:201). That ordering is the whole reason the hook runs where it does. It then raises the integration event (:59) and, only when there was a blob, schedules its durable deletion via ScheduleAvatarBlobDeletionAsync instead of enqueuing a post-commit action (:68-70); with no blob it returns Result.Success() directly (:73).
    • ScheduleAvatarBlobDeletionAsync (:76-86): awaits internalCommandScheduler.ScheduleAsync(new DeleteAvatarBlobInternalCommand(avatarBlobName), ...) (:78-80) and translates a failed schedule into Result.Failure(scheduled.Errors) (:85), which propagates up through OnAfterSoftDeleteAsync and aborts the erasure before anything is persisted (DeleteUserHandlerBase.cs:125-130).
    • Marker failure policy (DeleteUserHandlerBase.cs:144-153): the shared cache write is wrapped in a try/catch that swallows everything except OperationCanceledException and logs through the handler's own source-generated SoftDeletedMarkerFailed (:152, declared at :200). The reasoning is stated on the base (:21-29): the deletion is already committed by the time this runs, the marker only shortens the window in which an already-issued token keeps working, and a cache fault must not turn a successful, irreversible erasure into a failure the caller would retry. That is the shape ADR-096 generalized into a policy.
  • Why it's built this way: [Rubric §11, Security]. Access tokens are self-contained and valid until they expire, so deleting an account does not by itself stop a token already in the wild; the marker is what lets the shared SoftDeletedUserMiddleware reject those requests, and it lives in the base so every application gets the identical revocation window rather than each one re-implementing it in its own tail (DeleteUserHandlerBase.cs:21-25). Refresh sessions need no equivalent treatment, and the base says why: they live in their own table and the refresh flow re-fetches the account through the soft-delete query filter, so every outstanding session stops working the moment this commits (DeleteUserHandlerBase.cs:107-111, echoed on the aggregate at User.cs:478-483). Treating the access-token marker as best-effort is the correct trade: erasure is the promise that must hold, and the token window is bounded anyway. [Rubric §30]: the avatar photo is personal data too (BR-116a), so its deletion is scheduled durably rather than merely being a best-effort call.
  • Where it's used: resolved as ICommandHandler<DeleteUserCommand, Result> and invoked by UsersController's DeleteAsync (UsersController.cs:166-181), which returns 204 on success; the callers are the Profile page's self-service deletion and the organizer UserList. The UserDeleted integration event it raises is consumed downstream by Engagement's UserDeletedPointsHandler. The scheduled DeleteAvatarBlobInternalCommand is drained later by DeleteAvatarBlobInternalCommandHandler.
  • Caveats / not-in-source: two things to watch. First, the avatar delete no longer runs inline during the request: ScheduleAvatarBlobDeletionAsync only enrolls the internal-command row (DeleteUserHandler.cs:76-86), and the blob is actually removed later, off the request, whenever the internal-command processor drains it; a caller who deletes the account cannot assume the photo is already gone at the moment the 204 comes back. Second, this class's own summary still says the deletion "revokes the refresh token (BR-56)" (DeleteUserHandler.cs:15), which the code no longer does anywhere on this path: sessions are invalidated implicitly by the soft-delete filter, as both the base (DeleteUserHandlerBase.cs:107-111) and User.Delete (User.cs:478-483) now spell out. Trust the code; the summary line is stale.

ResetPasswordHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.ResetPassword · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:24 · Level 9 · class (sealed)

  • What it is: ADC's completion step for a forgotten password: redeem the single-use token, set the new credential, then clear the account's lockout. Like its change-preferences and get-preferences siblings it is an empty subclass; the workflow lives in ResetPasswordHandlerBase<TUser, TCommand>.
  • Depends on: ResetPasswordHandlerBase<TUser, TCommand> (base), IUnitOfWork, IPasswordHasher, IPasswordResetTokenService, ILoginProtectionService, ILogger<T>, IRefreshSessionStore, TimeProvider, User, and ResetPasswordCommand.
  • Concept introduced, revoking live sessions on a completed reset (SEC-Common-02). [Rubric §11, Security]. The constructor now also takes IRefreshSessionStore and a TimeProvider, forwarded straight to the base alongside the other five dependencies (ResetPasswordHandler.cs:24-39), so a completed reset revokes every live session on the account. A reset is the remediation for an account someone else is signed into, and without this addition it evicted nobody. ChangePasswordHandler carries the identical addition for the same reason.
  • Concept introduced, uniform failure as an anti-enumeration device. [Rubric §11, Security] assesses whether an anonymous endpoint leaks facts about accounts it will not authenticate. The base collapses every rejection to one error: Error.Unauthorized("Auth.InvalidResetToken", "The reset link is invalid or has expired. Please request a new one.", HandlerName) (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:116-120). An unknown token, an expired token, a token issued for a different address, a token past its validation-attempt cap and an account that has since vanished are all indistinguishable to the caller, which the <remarks> states as the point: the endpoint reveals nothing about which addresses hold accounts or which tokens exist (:18-22). The two failure branches differ only in the reason string handed to the log, "token rejected" (:81) and "account no longer resolvable" (:90), so an operator can still tell them apart while the client cannot. [Rubric §13, Observability & Operability]: both go through the handler's own source-generated [LoggerMessage] methods, PasswordResetRejected and PasswordResetCompleted (ResetPasswordHandlerBase.cs:122-128), and the success line records only the user id, never the address or the token.
  • Concept introduced, consume the token before the write. [Rubric §11, Security] again, and the ordering is the interesting part. The base redeems the token first (ResetPasswordHandlerBase.cs:76-78) and only then loads the user, hashes and saves. The comment states the trade explicitly (:58-60): leaving the token live until the write succeeds would open a replay window in which the same token redeems twice, so the token is burned up front, and the cost of a later invariant failure is that the user requests one more reset. Note also that the account is never named by the request: userId comes out of the redeemed token (:70), so the endpoint cannot be pointed at somebody else's account.
  • Walkthrough: a primary constructor taking the seven dependencies and forwarding all of them to the base (ResetPasswordHandler.cs:24-39), with an empty body (:40-41). The inherited HandleAsync (ResetPasswordHandlerBase.cs:65-114): null guard (:54); read the payload through the ICommandWithRequest<ResetPasswordRequest> constraint (:56), which is the whole reason the base never mentions ADC's command type; tokenService.ValidateAndConsumeAsync(request.Email, request.Token, ...) and the uniform failure on rejection (:61-68); load through the mutating repository by the id the token yielded, same uniform failure when the row is gone (:71-77); passwordHasher.HashPassword(request.NewPassword) and user.ChangePassword(newHash, newSalt) (:79-84), which returns the aggregate's own Result and is propagated untouched on failure; SaveChangesAsync (:86); then loginProtection.ResetFailedAttemptsAsync(request.Email, ...) (:89) with the one-line reason above it, that a user who reset the password because of a lockout must not stay locked out (:88); finally the success log and the result (:91-92).
  • Why it's built this way: the token material never touches the database. ADR-091 puts it in the cache, hashed at rest, with a per-email request throttle and a per-token validation-attempt cap owned by IPasswordResetTokenService (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:5-9), so the reset vertical adds no schema and no migration. The clean split between "who owns the token" (the service) and "who owns the credential" (the aggregate, through User.ChangePassword at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:402) is what lets this handler be twelve lines of forwarding.
  • Where it's used: resolved as ICommandHandler<ResetPasswordCommand, Result> and injected into PasswordResetController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:30), which exposes it as POST /Auth/reset-password with [Idempotent], [AllowAnonymous] and the shared auth-ip rate-limiting policy, all inherited from the framework base (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:99-102).
  • Caveats / not-in-source: User.ChangePassword raises the in-process UserPasswordChanged domain event (User.cs:416), so the successful save writes an outbox row as well as the new credential; what subscribes to it is outside this vertical. The lockout clear at ResetPasswordHandlerBase.cs:110 runs after the commit and is awaited without a try/catch, so a failure there surfaces to the caller even though the password has already changed.

GetUserPreferencesHandler

MMCA.ADC.Identity.Application · MMCA.ADC.Identity.Application.Users.UseCases.GetPreferences · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13 · Level 9 · class (sealed)

  • What it is: the read side of user preferences: given a user id, return the stored culture and theme. Another empty subclass, this one over GetUserPreferencesHandlerBase<TUser>.
  • Depends on: GetUserPreferencesHandlerBase<TUser> (base), IUnitOfWork, and User. Note what is absent: no ILogger. A query that only reads has nothing to announce.
  • Concept reinforced, the thin CQRS read handler (the subclass shape is taught at ChangePasswordHandler; the query pipeline at IQueryHandler<in TQuery, TResult>). [Rubric §6, CQRS & Event-Driven]: the base implements IQueryHandler<GetUserPreferencesQuery, Result<UserPreferencesResponse>> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21-22), so it runs through the query decorator chain with no Validating and no Transactional decorator, because a read opens no transaction. Both GetUserPreferencesQuery and UserPreferencesResponse were byte-identical in the two applications and were hoisted whole (:10-13), which is why this base is generic in the aggregate only, and why its constraint is the lighter AuditableBaseEntity<UserIdentifierType>, IUserPreferences rather than the aggregate-root constraint the write bases need (:23). [Rubric §27, Internationalization]: this is the server end of "remember my language", reading User.PreferredCulture straight off the aggregate.
  • Walkthrough: a primary constructor taking IUnitOfWork and forwarding it (GetUserPreferencesHandler.cs:13-14), empty body. The inherited HandleAsync (GetUserPreferencesHandlerBase.cs:33-45) guards the query for null (:37), resolves unitOfWork.GetReadRepository<TUser, UserIdentifierType>() (:39), calls GetByIdAsync(query.UserId, ...) (:40), and returns either Error.NotFound tagged with HandlerName and the aggregate's type name or Result.Success(new UserPreferencesResponse(user.PreferredCulture, user.PreferredTheme)) (:41-44).
  • Why it's built this way: the base's <remarks> (GetUserPreferencesHandlerBase.cs:15-19) records that the two application copies disagreed on the repository (ADC read, Store write) and that the read repository is the correct choice for a handler which never calls SaveChangesAsync, so Store gained a no-tracking read on adoption. That is the ordinary payoff of consolidating duplicated code: the merge forces a decision, and the better of the two behaviors wins for everyone.
  • Where it's used: resolved as IQueryHandler<GetUserPreferencesQuery, Result<UserPreferencesResponse>> and injected into AuthController (AuthController.cs:36) as GET /Auth/preferences; the response seeds the client's culture and theme at startup.

AttendeeQueryServiceGrpcAdapter

MMCA.ADC.Identity.Contracts · MMCA.ADC.Identity.Contracts · MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/AttendeeQueryServiceGrpcAdapter.cs:14 · Level 9 · class (internal, sealed)

  • What it is: the gRPC-backed client implementation of IAttendeeQueryService. It answers "which user ids hold the Attendee role" by calling the extracted Identity service instead of reading the local database.
  • Depends on: IAttendeeQueryService (implemented, from MMCA.ADC.Identity.Shared), the generated AttendeeQueryService.AttendeeQueryServiceClient and GetAttendeeUserIdsRequest types compiled from the .proto in this project, and the UserIdentifierType alias.
  • Concept introduced, the adapter that makes extraction invisible to callers. [Rubric §7, Microservices Readiness] assesses whether a module can be lifted into its own process without rewriting the code that consumes it. This class is the concrete payoff of the interface indirection set up in the Shared layer. Notification depends on the C# interface; in the monolith that interface resolves to Identity's in-process AttendeeQueryService, and in the extracted topology the composition root swaps in this adapter. The consuming code does not change, does not learn about gRPC, and never holds a generated proto type. The class doc comment (AttendeeQueryServiceGrpcAdapter.cs:6-13) says exactly that, and it is why the project is named .Contracts and holds both the .proto and the hand-written adapter (ADR-007, ADR-008). Note the accessibility: the adapter is internal (:14), so consumers cannot name it; the only way to get it is through DependencyInjection in the same project.
  • Walkthrough
    • Primary constructor (:14-15): takes the generated typed client and nothing else.
    • CallDeadline = TimeSpan.FromSeconds(5) (:20): a per-call deadline, and the comment (:17-19) explains why it is far tighter than the shared resilience pipeline's 30s attempt and 90s total budget. The failure being defended against is a hung peer, not a refused one: a refused call fails immediately, but a hung one would stall the broadcast-notification request that triggered this lookup for the full pipeline budget. Transport failures propagate to the caller by design rather than degrading into an empty audience.
    • GetAttendeeUserIdsAsync (:23-34): one call, passing deadline: DateTime.UtcNow.Add(CallDeadline) alongside the cancellation token (:26-29), then return [.. response.UserIds] (:33). The collection expression materializes protobuf's RepeatedField into a plain IReadOnlyList, which the comment (:31-32) notes is deliberate so callers do not leak the generated type; because UserIdentifierType is int, the projection is a no-op cast.
  • Why it's built this way: [Rubric §29, Resilience & Business Continuity]. Choosing a deadline shorter than the retry budget is the difference between "this dependency is slow" and "this request never returns". Letting transport failures surface rather than swallowing them means a broadcast that could not determine its audience fails visibly instead of quietly notifying nobody.
  • Where it's used: registered by DependencyInjection's AddIdentityAttendeeClient in this same project, which the Notification service host calls inside its application-pipeline registration (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:230). Its server-side counterpart is AttendeesGrpcService.
  • Caveats / not-in-source: the interface returns a plain list rather than a Result, so a transport fault reaches the caller as a raw RpcException after the Polly pipeline gives up; there is no Result.Failure translation on this path.

AttendeesGrpcService

MMCA.ADC.Identity.Service · MMCA.ADC.Identity.Service.Grpc · MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Grpc/AttendeesGrpcService.cs:19 · Level 9 · class (sealed)

  • What it is: the server half of the same bridge. It subclasses the generated AttendeeQueryService.AttendeeQueryServiceBase and answers the RPC by delegating to whatever IAttendeeQueryService is registered in the Identity service host, which in that process is the real in-process implementation reading the Identity database.
  • Depends on: IAttendeeQueryService (injected), the generated AttendeeQueryServiceBase, GetAttendeeUserIdsRequest and GetAttendeeUserIdsResponse from attendee_query.proto, and Grpc.Core.ServerCallContext.
  • Concept introduced, the thin gRPC ingress over an existing interface. [Rubric §9, API & Contract Design] assesses whether a published contract is a deliberate surface rather than a leaked internal shape, and [Rubric §7, Microservices Readiness] covers the extraction path. The important property is that this class contains no logic: it translates a proto request into an interface call and the returned list into a proto response. That is what makes the two deployment topologies behaviorally identical. The doc comment (AttendeesGrpcService.cs:11-17) names the one piece of error handling that is not written here: exceptions from the inner service propagate as plain exceptions, and the shared server interceptor GrpcResultExceptionInterceptor translates a ResultFailureException into an RpcException carrying the right gRPC status code. A hand-written try/catch here would duplicate a cross-cutting concern that the pipeline already owns.
  • Walkthrough (GetAttendeeUserIds, :23-35)
    1. Argument guards on both request and context (:27-28). The generated base is a public wire surface, so both are checked even though the framework supplies them.
    2. await inner.GetAttendeeUserIdsAsync(context.CancellationToken) (:30). Notice which token is passed: the server call context's token, so a client that hangs up or hits its deadline cancels the database read on this side rather than letting it run to completion for nobody.
    3. Response construction (:32-34): a fresh GetAttendeeUserIdsResponse, then response.UserIds.AddRange(userIds). Protobuf repeated fields are read-only collections that are populated, never assigned.
  • Why it's built this way: [Rubric §11, Security]. The endpoint is mapped with app.MapGrpcService<AttendeesGrpcService>().RequireAuthorization() (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:373), and the comment above the mapping (:327-334) is worth reading in full: the response enumerates attendee user ids, so internal-only ingress is not considered sufficient, and the authorization holds today only because the single caller chain is HTTP-triggered (Notification's AttendeeNotificationRecipientProvider under an organizer-authorized controller), which means an inbound bearer always exists for JwtForwardingClientInterceptor to forward. The comment states the standing condition explicitly: a scheduler or broker-consumer path would run with no HttpContext, forward no bearer, and would need its own credential before it ships.
  • Where it's used: registered through AddGrpcServiceDefaults() (Program.cs:340, whose comment at :298-303 names this class as the endpoint it publishes) and mapped at Program.cs:373; the only client is AttendeeQueryServiceGrpcAdapter. The service serves it over Kestrel's HTTP/2 cleartext channel, the transport profile of ADR-012. Reflection is mapped only in Development (:337-340).

DependencyInjection

MMCA.ADC.Identity.Contracts · MMCA.ADC.Identity.Contracts · MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/DependencyInjection.cs:14 · Level 10 · class (static)

  • What it is: the one registration helper the .Contracts project exposes. AddIdentityAttendeeClient(serviceName) wires a typed gRPC client for the extracted Identity service and swaps IAttendeeQueryService over to AttendeeQueryServiceGrpcAdapter.
  • Depends on: IServiceCollection and ServiceCollectionDescriptorExtensions.Replace; the generated AttendeeQueryService.AttendeeQueryServiceClient; IAttendeeQueryService and AttendeeQueryServiceGrpcAdapter; AddTypedGrpcClient<TClient> from MMCA.Common.Grpc.
  • Concept introduced, Replace instead of TryAdd, and why the call site's position is load-bearing. [Rubric §7, Microservices Readiness] and [Rubric §3, Clean Architecture]. Everywhere else in this codebase module registration uses TryAdd, so a host can pre-empt a default. Here the semantics are inverted deliberately, and the doc comment (DependencyInjection.cs:24-32) gives the reason: by the time a consuming host calls this, the container already holds one of two registrations for IAttendeeQueryService. If the Identity module is enabled it holds the real in-process AttendeeQueryService; if Identity is disabled it holds the DisabledAttendeeQueryService stub that IdentityModule's RegisterDisabledStubs put there so DI validation would still succeed. Replace overwrites either one, so after this call the resolved service is the gRPC adapter regardless of which path the host took; TryAdd would silently do nothing in both cases. The consequence is an ordering rule, stated in the same comment (:33-37): call this after ModuleLoader.DiscoverAndRegister(...), so the in-process or stub registration is in the container by the time Replace looks for it. Calling it earlier is not an error the compiler or the container reports; it simply leaves the wrong implementation in place.
  • Walkthrough: the method lives inside an extension(IServiceCollection services) block (:16), the workspace idiom for DI registration (see primer §4). AddIdentityAttendeeClient(string serviceName = "identity") (:41) does two things: AddTypedGrpcClient<AttendeeQueryService.AttendeeQueryServiceClient>(serviceName) (:43), which the framework wires to Aspire service discovery at http://{serviceName} over HTTP/2 cleartext with the standard JwtForwardingClientInterceptor and Polly resilience handler; and services.Replace(ServiceDescriptor.Scoped<IAttendeeQueryService, AttendeeQueryServiceGrpcAdapter>()) (:47), with the inline comment restating the Replace-not-TryAdd rule at the point of use (:45-46). It then returns services for chaining (:49). The serviceName default of "identity" matches the AppHost resource name, so the common case passes no argument.
  • Why it's built this way: keeping this helper in the .Contracts project rather than in the consuming service means the knowledge of "how you talk to Identity remotely" lives once, next to the .proto that defines the call, and every future consumer gets it with a project reference plus one line (ADR-007). The Scoped lifetime matches the in-process implementation it replaces, so swapping transports changes no lifetime assumption anywhere in the graph.
  • Where it's used: called by the Notification service host as the second of three ordered steps inside AddMmcaApplicationPipeline, between module registration and broker messaging (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:228-232). That enclosing call is what makes the ordering rule structural rather than a convention: the pipeline helper runs AddApplication(), then every registration callback, then AddApplicationDecorators(), and seals, so Replace is guaranteed to see the module's registration and the decorators are guaranteed to wrap what results (:194-214, which repeats the Replace rationale at the call site). The matching AppHost reference is noted at MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:263.
  • Caveats / not-in-source: this is the only public member of the class today, so the .Contracts project's DI surface is exactly this one call.

ModuleApplicationDbContext

MMCA.ADC.Identity.Infrastructure · MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15 · Level 12 · class (abstract)

  • What it is: the Identity module's abstract EF Core context. It declares the module's one entity set, Users, and inherits everything else from the framework ApplicationDbContext.
  • Depends on: ApplicationDbContext (base), IEntityConfigurationAssemblyProvider, PhysicalDataSource, EF Core's DbContextOptions, IServiceProvider, and User.
  • Concept reinforced, one context class per engine, never one per module. [Rubric §8, Data Architecture] assesses how ownership of tables is expressed. The name can mislead on first reading: this type is not what gets instantiated. It is an abstract declaration of what Identity contributes to a context, and the concrete per-engine class (SQLServerDbContext in production today) inherits it and supplies the provider options (ADR-006, ADR-018). Every other module declares a same-named abstract class in its own namespace, and the doc comment (ModuleApplicationDbContext.cs:9-14) states the division of labour plainly: the base handles audit fields, soft deletes, and domain-event dispatch through EF interceptors, so a module context is a declaration of entity sets and nothing more.
  • Walkthrough: a primary constructor forwarding all four parameters straight to the base (ModuleApplicationDbContext.cs:15-20), then the single member internal DbSet<User> Users { get; set; } (:22). Note the accessibility: internal, not public. Application code reaches users through IUnitOfWork and the repositories, so nothing outside the Infrastructure assembly has a reason to touch the set directly. The mapping is not here either: it is discovered from the assembly named by IEntityConfigurationAssemblyProvider and supplied by UserConfiguration.
  • Why it's built this way: keeping the module's contribution abstract is what lets the same entity declarations be hosted by a SQL Server context in production and, with no code change, by a different engine's context. It is also what makes the "never split the context per module" rule enforceable: modules add abstract declarations, they never introduce a second concrete context class (ADR-006).
  • Where it's used: inherited by the concrete engine context the framework's physical context factory builds for the ADC_Identity database (the connection string the extracted service resolves, MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:52); that database also carries its own dbo.OutboxMessages table, so Identity's outbox never contends with another service's.

UserConfiguration

MMCA.ADC.Identity.Infrastructure · MMCA.ADC.Identity.Infrastructure.Persistence.EntityConfiguration · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:12 · Level 14 · class (internal, sealed)

  • What it is: the EF Core mapping for the User aggregate: column types and widths, the value-object conversion for Email, the ignored computed properties, and three indexes.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> (base), EmailValueConverter, User and UserInvariants, and EF Core's EntityTypeBuilder<T>.
  • Concept introduced, the engine-typed configuration base and the shared length constants. [Rubric §8, Data Architecture] assesses whether the schema is declared explicitly rather than inferred, and [Rubric §15, Best Practices & Code Quality] covers why the numbers are not literals. Two things are worth internalizing. First, the base class is what routes this entity to a physical data source: deriving from EntityTypeConfigurationSQLServer<User, UserIdentifierType> (UserConfiguration.cs:13) declares the engine, and the database name resolves from the module, which is how User lands in ADC_Identity rather than in a shared database. base.Configure(builder) (:18) must run first, because it applies the framework conventions (key, audit columns, the soft-delete global query filter) that this method then refines. Second, every length that also matters to the domain is read from UserInvariants rather than typed twice: EmailMaxLength (:22), FirstNameMaxLength (:29), LastNameMaxLength (:33), and DeviceFieldMaxLength on all seven device columns (:54, :58, :62, :68, :71, :74, :77). The constants themselves are 100, 100, 100 and 256 (plus 32 for the terms version, TermsVersionMaxLength) (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserInvariants.cs:12-24). That is the mechanism that stops an over-long name from passing the domain check and then being truncated by the database.
  • Walkthrough
    • Email (:20-24): HasConversion(new EmailValueConverter()) maps the Email value object onto a plain column, then HasMaxLength, IsUnicode(false) and IsRequired(). IsUnicode(false) recurs on every string whose values are ASCII by definition (emails, role names, device identifiers, provider keys, culture codes, URLs): these become varchar, not nvarchar, halving the storage. The rule is the split, not a blanket default: human-entered text omits IsUnicode(false) and so stays nvarchar (M148).
    • Names (:26-34): FirstName and LastName, both required, both bounded by the domain constants above, and both deliberately left Unicode. The comment (:26-27) states why: names are human text in any script, so a non-Latin name is stored as entered instead of being corrupted to ? by a varchar code page.
    • Device metadata (:52-78): seven optional, MAUI-only columns (BR-201, BR-202), all bounded by DeviceFieldMaxLength. The machine identifiers DeviceId, DeviceFormFactor, DevicePlatform and DeviceType stay varchar, while DeviceModel, DeviceManufacturer and the user-chosen DeviceName are free text and stay nvarchar (:65-74).
    • Credentials (:36-41): PasswordHash and PasswordSalt are configured with no options at all, deliberately. The comment states that EF maps byte[] to varbinary(max) by default so no explicit length is needed (BR-204), and that external-login accounts hold empty arrays.
    • Fixed-width strings: Role at 50 (:43-46), LoginProvider at 50 and ProviderKey at 256 (:80-87), PreferredCulture and PreferredTheme at 10 each (:89-96), AvatarUrl at 512 (:98-101).
    • LinkedSpeakerId (:50): configured as a plain scalar property with no relationship at all. That is the visible consequence of ADR-006: Speaker lives in another database, so there is no foreign key to declare. The comment above it (:48-49) records the intent, a nullable link that is unique when non-null (BR-208, BR-209).
    • LockedOn (:103-105): the administrative lock instant (users:manage), nullable and configured with no explicit options; the comment states that null means the account is usable and the instant is what an operator needs to answer "since when" it was locked.
    • Terms acceptance (:107-113): AcceptedTermsVersion is bounded by TermsVersionMaxLength and IsUnicode(false) (:109-111), and TermsAcceptedOn is a nullable instant configured with no options (:113). Both are nullable because accounts that predate the terms, and accounts created through external sign-in, have accepted nothing yet (comment :107-108).
    • IsEmailConfirmed (:115-120): required, with HasDefaultValue(false) (ADR-116). The comment explains the default: it keeps the column non-nullable for rows written by anything that does not set the flag, and the migration backfills the accounts that existed before it did.
    • Ignored members (:122-125): FullName, IsExternalLogin and IsLocked are computed on the aggregate, so builder.Ignore keeps EF from expecting columns for them.
    • Indexes (:127-138): a unique index on Email with no hand-written filter (:130), which is the BR-200 "email is the identity" rule enforced at the storage layer; a unique filtered index on LinkedSpeakerId with HasFilter("[LinkedSpeakerId] IS NOT NULL") (:132-134), which makes the User-to-Speaker link 1:1 while still allowing the many users who have no linked speaker; and a unique filtered composite on (LoginProvider, ProviderKey) (:136-138), so one external identity cannot be attached to two accounts.
  • Concept introduced, what soft delete does to a unique index. [Rubric §8, Data Architecture]. Read the Email index again: it declares IsUnique() and no filter (:130), yet the row it guards is never physically deleted. A soft-deleted row still occupies its unique slot, so without help the address of a deleted account could never be reused. The help is a model-finalizing convention rather than a hand-written filter, and the comment above the index block says so (:127-129): SoftDeleteUniqueIndexConvention walks every soft-deletable entity type at model finalization (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:46-50), building the predicate through the same SoftDeleteFilterSql.Build a hand-authored index would reach (:57), and no-opping for Cosmos (:43-44). The behavior to internalize is that a hand-authored filter is extended, not skipped: an index with no filter gets the soft-delete clause outright (:66-71), an index that already constrains the soft-delete column is left exactly as it is (:73-76), and any other hand-written predicate is kept and AND-ed with the soft-delete clause (:78-80). So all three indexes here end up excluding deleted rows: Email gains the clause alone, and the two that spell their own HasFilter keep their own predicate plus the appended one. The convention's doc comment records that skipping them, as an earlier version did, silently left exactly the hand-authored partial-unique indexes as the only ones a soft-deleted row could keep blocking (:19-26).
  • Why it's built this way: [Rubric §12, Performance & Scalability]: the two hand-filtered indexes are sparse-column cases where the majority of rows are null, so filtering keeps each index small and keeps writes to the common rows out of it entirely. [Rubric §11, Security]: the unique constraints on email and on the provider pair are the last line of defence behind the application-level uniqueness probes, so a race between two concurrent registrations fails at the database rather than producing two accounts.
  • Where it's used: discovered by assembly scan through IEntityConfigurationAssemblyProvider and applied when the concrete engine context builds the model declared by ModuleApplicationDbContext; the resulting schema is materialized by the per-service Identity migrations project.
  • Caveats / not-in-source: two small mismatches are worth knowing. First, the file comment warns that spelling the soft-delete clause here "would double it" (:127-129), but the convention guards that case with SoftDeleteFilterSql.ContainsPredicate and leaves such a filter untouched (SoftDeleteUniqueIndexConvention.cs:73-76), so the comment is more cautious than the code requires. Second, the erasure path is what frees the two provider slots on an already-deleted row: User.Anonymize nulls LoginProvider and ProviderKey and rewrites the address to a per-id deleted-{Id}@anonymized.invalid placeholder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:507-548); LinkedSpeakerId is not cleared there, but because the convention appends the soft-delete clause to that index too, a soft-deleted user no longer blocks the slot.

IdentityModuleDbSeeder

MMCA.ADC.Identity.Infrastructure · MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts.Seeding · MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:28 · Level 15 · class

  • What it is: the development and test seeder for Identity. It supplies three fixed accounts (one Organizer, two Attendees) to the framework's IdentityModuleDbSeederBase<TUser>, which owns the per-account idiom.
  • Depends on: IdentityModuleDbSeederBase<TUser> (base, itself a DbSeeder), IUnitOfWork, IPasswordHasher, SeedAccount, Email, User and UserRole, and Result in its generic form.
  • Concept introduced, the hoisted seeder with two typed hooks. [Rubric §17, DevOps] assesses repeatable environment setup, and [Rubric §15, Best Practices & Code Quality] covers the de-duplication. The five-step idiom (normalize the email, skip if it already exists, hash the password, build the aggregate, add, save) was written out five times across the two applications' Identity modules and now lives once in the base (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeederBase.cs:93-114). Only two things could not be hoisted, and the base's <remarks> (:13-24) names both: CreateUser, because the two applications' User.Create(...) factories take the same values in different parameter orders and only the application can spell its own role vocabulary; and EmailExistsAsync, because the existence predicate must be written against the concrete User (never an interface member) so EF translates it byte-for-byte the way it did before the hoist. That second point is the general lesson for hoisting anything that ends up inside an expression tree: a where TUser : ISomething constraint would compile and then fail at query translation.
  • Concept introduced, seeding gated where the gate has one home. [Rubric §11, Security]. The base exposes a ShouldSeed opt-in that defaults to true (IdentityModuleDbSeederBase.cs:58), and this subclass deliberately does not override it (IdentityModuleDbSeeder.cs:18-20): ADC's Seeding:IncludeSampleUsers gate stays in IdentityModuleSeeder in the API layer, which reads the key with GetValue<bool> and returns before constructing this seeder at all (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:29-31). One gate, one home. The class doc carries an explicit security notice (IdentityModuleDbSeeder.cs:22-26): the seed credentials ("Admin123!", "Password") are intentionally weak, exist only for local development convenience, and a deployed host must either disable seeding or supply environment-sourced secrets. The default of false when the configuration key is absent (IdentityModuleSeeder.cs:25-28) is what makes that safe by omission rather than by remembering.
  • Walkthrough
    • Accounts (:33-38): a collection-expression IReadOnlyList<SeedAccount> with three entries, each (email, password, role, firstName, lastName). The roles come from UserRole's constants, not string literals.
    • EmailExistsAsync (:41-48): resolves the mutating repository through UnitOfWork.GetRepository<User, UserIdentifierType>() and calls ExistsAsync(u => u.Email == email, ...). The predicate compares the Email value object, which is why the base normalizes the raw string with Email.Create(account.Email).Value first (IdentityModuleDbSeederBase.cs:95-96): both sides of the comparison then go through the same value converter and the SQL matches.
    • CreateUser (:51-62): a null guard, then User.Create(email, firstName, lastName, passwordHash, passwordSalt, role) in ADC's parameter order, returning the aggregate's own generic Result. A failure here is not thrown: the base skips that one account and moves on (IdentityModuleDbSeederBase.cs:105-109).
    • Idempotency and isolation come from the base loop: SeedAsync short-circuits on ShouldSeed and then iterates the accounts (:60-71), and each account is saved individually (:110-112), so re-running against an already-seeded database is a no-op and one invalid account cannot roll back the others.
  • Why it's built this way: seeding goes through IUnitOfWork and the domain factory rather than through raw SQL or EF HasData, so seeded rows satisfy exactly the same invariants, audit stamping, and password hashing (ADR-032) as rows created through the API. That is what makes the seeded database a faithful small copy of a real one rather than a fixture that only looks like one.
  • Where it's used: constructed by IdentityModuleSeeder (IdentityModuleSeeder.cs:33-36), which the module system invokes through the IModuleSeeder contract during database initialization (DatabaseInitializationExtensions); in practice that means the local Aspire AppHost and E2E CI, and nothing else.
  • Caveats / not-in-source: the account list is a compile-time constant, so changing which accounts exist in a development environment is a code change and a rebuild, not configuration. The class is also the one non-sealed type in this unit (IdentityModuleDbSeeder.cs:28), a plain public class; nothing in the repo subclasses it today.

⬅ ADC Engagement Live Layer (Real-Time Polls & Session Q&A) • Index • ADC Application Host, UI Shell & Cross-Module Composition ➡