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).
The User aggregate: credentials, profile, and cross-context links in one root
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: anAssemblyReferenceand aClassReferencein 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:1in 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 atypeof(...)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,11andMMCA.ADC.Identity.Infrastructure/AssemblyReference.cs:5,11). - Walkthrough:
AssemblyReference.Assembly(:7) is apublic static readonly Assembly;AssemblyName(:8) is its short name, falling back tostring.Emptywhen 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-staticClassReferenceis 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 viatypeof(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
ClassReferenceis the live one:DependencyInjectionfor the Application layer passes it toservices.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 inMMCA.ADC/Sourcetoday; 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
AssemblyReferencenorClassReferenceof 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
UserIdentifierTypealias (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):UserIdis "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 isme/avatarand the id comes fromICurrentUserService(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. ContrastGetUsersQuerybelow, 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'sGetAvatarAsync(MMCA.ADC.Identity.API/Controllers/UsersController.cs:59-61), handled byGetUserAvatarHandler.
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.resxfiles (ADR-027). - Depends on: nothing first-party. At runtime its
.resxsiblings are loaded throughIStringLocalizerFactory(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 domainError'sCode(for example"User.Email.Empty") is the resource key, and the sharedIErrorLocalizerlooks 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 inIdentityErrorResources.resxandIdentityErrorResources.es.resxrather 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 errorCode. And runtime-variable messages (those that interpolate a user-supplied value) are deliberately omitted from the.resxso 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) resolvesIStringLocalizerFactory, callsCreate(typeof(TResource)), and registers the result as one moreErrorResourceSourcesingleton (:124-125); the convention "a.resxnamed after the type, sitting beside it" is what binds the strings, so an empty marker class is exactly enough. The framework registers its ownErrorResourcesthrough that very same helper insideAddErrorLocalization()(:107-113, the call at:111), which is what makes the mechanism additive rather than replace-only: each source is a plainAddSingleton, 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-livedExternalLogincookie 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 ofHttpContext, 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:trueonly when the provider explicitly asserts verification (Google'semail_verifiedclaim); providers that assert nothing (GitHub's OAuth flow) yieldfalse. 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
AuthenticationServiceinside the external-login workflow (MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:245-246); implemented byHttpContextExternalLoginEmailVerifier, which re-authenticates theExternalLoginscheme and parses theemail_verifiedclaim (MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:32-35), returningfalsewhen there is noHttpContext(: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
IModulethat theModuleLoaderdiscovers by reflection and registers. Identity is a leaf in the module dependency graph: it declares no prerequisites (doc commentIdentityModule.cs:9-12). - Depends on:
IModule;ApplicationSettings; its ownDependencyInjection'sAddIdentityModule;IAttendeeQueryServiceandDisabledAttendeeQueryServicefrom the Shared layer;Microsoft.Extensions.ConfigurationandMicrosoft.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 onIAttendeeQueryService(Notification needs the attendee id list for a broadcast), so this method registersDisabledAttendeeQueryServiceas 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, aservices.Replace(...)of the scopedAttendeeQueryServiceGrpcAdapter; the comment at:45-46records whyReplaceand notTryAdd: 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 toservices.AddIdentityModule(applicationSettings); note that theIConfigurationBuilderparameter 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 separateIModuleSeeder,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.Serviceonly 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
ModuleLoaderat host startup;RegisterDisabledStubsruns in hosts where the Identity module is not enabled, for exampleMMCA.ADC.Notification.Service, whose composition root then callsAddIdentityAttendeeClient()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, plusAddModuleIdentityAPI(), which applies the module's role-to-permission grants and wires the OAuth email-verification gate. - Depends on:
IServiceCollectionandTryAddScoped(Microsoft.Extensions.DependencyInjection);ApplicationSettings; the Application-layerAddModuleIdentityApplication(seeDependencyInjectionfor the Application layer) and the Infrastructure-layerAddModuleIdentityInfrastructure;AddPermissionsfromMMCA.Common.API.AuthorizationandIdentityPermissionGrants.Applyfrom the module's Shared project;IExternalLoginEmailVerifierandHttpContextExternalLoginEmailVerifier. - 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 offIServiceCollectionthrough 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) callsAddModuleIdentityApplication(applicationSettings)(:29),AddModuleIdentityInfrastructure()(:30), andAddModuleIdentityAPI()(:31), then returnsservicesfor chaining (:33).AddModuleIdentityAPI()(:42-58) does two things. First it callsservices.AddPermissions(IdentityPermissionGrants.Apply), handing the whole grant map to the framework'sAddPermissionshelper 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()plusTryAddScoped<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
IdentityModulecalls, so module wiring stays discoverable. The grant map itself was pulled out intoIdentityPermissionGrantsin 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 thanAddScoped) leaves the door open for a host to pre-register a different verifier. - Where it's used:
AddIdentityModuleis invoked byIdentityModule'sRegister(MMCA.ADC.Identity.API/IdentityModule.cs:23-24) during topological module registration by theModuleLoader.
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
IAttendeeQueryServicecontract. 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);IUnitOfWorkandIReadRepository<TEntity, TIdentifierType>;User,UserRoleandSyntheticAccounts(Domain); theUserIdentifierTypealias. - 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 referenceMMCA.ADC.Identity.Domain. The interface therefore lives inMMCA.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 wholeUserrows, so the wire and the heap carry two columns per attendee, not the aggregate. - Walkthrough (primary-constructor injection of
IUnitOfWork,:12; the class isinternal sealed, so nothing outside the Application assembly can bind to the concrete type)- 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. - 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 anonymousId/Emailpair), the second the WHERE predicate. Note the.Value:Userstores its role as a plainstring(MMCA.ADC.Identity.Domain/Users/User.cs:71) andUserRoleis 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-11means, since no explicitIsDeletedtest appears here). - 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 thatEmailis 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-insensitiveEndsWithof@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 theIReadOnlyList<UserIdentifierType>the contract promises.
- Read repository (
- 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
IAttendeeQueryServiceimplementation by the Application-layerDependencyInjection(MMCA.ADC.Identity.Application/DependencyInjection.cs:39); consumed by the Notification module. In the extracted topology the registration is replaced byAttendeeQueryServiceGrpcAdapter, and this class becomes the code behindAttendeesGrpcServiceon 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
nullinside a successful result when none is set. - Depends on:
IQueryHandler<in TQuery, TResult>;GetUserAvatarQuery;IUnitOfWork;User;UserAvatarDTO;ResultandError. - 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 yieldsResult.Success(new UserAvatarDTO(user.AvatarUrl))with a null URL inside (:23, the property isstring?atMMCA.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 yieldsError.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) mappingnulltoResult.Failure<UserAvatarDTO>(Error.NotFound.WithSource(nameof(GetUserAvatarHandler)).WithTarget(nameof(User)))and a found user toResult.Success(new UserAvatarDTO(user.AvatarUrl)). Eleven lines of body, no branching beyond that. - Why it's built this way:
GetReadRepositorystates the intent in the type, and the fluentWithSource/WithTargeterror 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
UsersControllerasIQueryHandler<GetUserAvatarQuery, Result<UserAvatarDTO>>(MMCA.ADC.Identity.API/Controllers/UsersController.cs:35) and invoked byGetAvatarAsyncforGET /Users/me/avatar(:51-61); its write-side counterparts are theSetUserAvatarCommandandRemoveUserAvatarCommandhandlers 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'sUsers/me/legal-acceptanceendpoints. - Depends on:
ILegalAcceptanceService(the contract);IUnitOfWork;TimeProvider;User(AcceptTerms,AcceptedTermsVersion,TermsAcceptedOn);LegalAcceptanceDTO;ResultandError; theUserIdentifierTypealias. - 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 inUser.AcceptTerms(MMCA.ADC.Identity.Domain/Users/User.cs:314), which checks the version withUserInvariants.EnsureTermsVersionIsValidbefore stamping it. The standing shown to the client is derived by the sharedLegalAcceptanceDTO.Evaluate(MMCA.Common.Shared/Legal/LegalAcceptanceDTO.cs:42), soIsCurrentis 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
IUnitOfWorkandTimeProvider,:21-23)GetForCurrentUserAsync(:26-40), a read:GetReadRepository<User, UserIdentifierType>().GetByIdAsync(currentUserId, ...)(:31-34); a missing user returnsError.NotFoundwith source and target set (:37-38); otherwise it returnsLegalAcceptanceDTO.Evaluate(currentVersion, user.AcceptedTermsVersion, user.TermsAcceptedOn)(:39).AcceptForCurrentUserAsync(:43-67), a write: it loads the user through the mutableGetRepository<User, UserIdentifierType>()(:48-51), returnsNotFoundwhen absent (:52-56), then callsuser.AcceptTerms(version, timeProvider.GetUtcNow().UtcDateTime)(:58). A failed acceptance (an invalid version) is returned asResult.Failure<LegalAcceptanceDTO>(acceptResult.Errors)before anything is saved (:59-62); on success it callsunitOfWork.SaveChangesAsync(:64) and returns the re-evaluated standing (:66).
- Why it's built this way: injecting
TimeProviderrather than readingDateTime.UtcNowkeeps the stamped instant testable. New local sign-ups are stamped at registration byAuthenticationService'sCreateUser, 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-layerDependencyInjection(MMCA.ADC.Identity.Application/DependencyInjection.cs:47), explicitly because the convention scan only finds handlers, mappers and validators; the other usage site isLegalAcceptanceServiceTests. The HTTP surface isLegalAcceptanceController.
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, thespeaker_idclaim, the outbox-atomicUserRegisteredintegration event, and the entire external OAuth login flow.Depends on:
AuthenticationServiceBase<TUser>andIAuthenticationService;IUnitOfWork,IPasswordHasher,ILoginProtectionService,AuthenticationValidatorsandIAuthSessionIssuer(:56), plusIOptions<EmailConfirmationSettings>(:57) and an optionalTimeProvider? timeProvider = null(:58, used only to stamp the terms acceptance instant, falling back toTimeProvider.System), the rest passed straight through to the base constructor (:60-66, withtwoFactor: null);IExternalLoginEmailVerifier;User,UserRole,UserRegistered,Email,ResultandError;AuthenticationResponseandRegisterRequest;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) andValidateRefreshCandidateAsync(:115-116), added for ADR-116: both delegate to the privateCheckNotLocked(:118-124), which refusesError.Unauthorized("Auth.AccountLocked", ...)whenuser.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 withignoreQueryFilters: true.CreateUser(:147-168), which fixes ADC's default role asUserRole.Attendee.Value(BR-45) and, when a Terms of Service version is configured (Legal:CurrentTermsVersion, read through the base'sCurrentTermsVersion), stamps the new account with it: afterUser.Createsucceeds it callsuser.AcceptTerms(termsVersion, (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime)(:163-167;User.AcceptTermsis atMMCA.ADC.Identity.Domain/Users/User.cs:314) and surfaces any failure asResult.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 byLegalAcceptanceService.CreateAccessToken(:171-172), which appends thespeaker_idclaim when the account is linked to a speaker; the claim is built by the privateSpeakerClaimshelper (:405), returningnullwhenLinkedSpeakerIdhas 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 uniqueEmailindex is filtered on[IsDeleted] = 0, because the framework'sSoftDeleteUniqueIndexConventionappends that clause, so a soft-deleted row does not block the insert at the database level either. What theignoreQueryFilters: truebypass 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
EmailVerifiedflag settles.[Rubric §6, CQRS & Event-Driven],[Rubric §8, Data Architecture],[Rubric §29, Resilience & Business Continuity].User.Idis a database-generated identity column, so at the moment the aggregate is created the id is still0. The outbox serializes an event's payload at capture time, so raisingUserRegisteredbefore the insert would persistUserId = 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 withnewand wraps the base implementation inUnitOfWork.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 anIAuthenticationService, since the base method is not virtual.OnUserRegisteredAsync(:188-194) runs after the base's first save, when the identity value exists: it callsuser.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 passesEmailVerified: 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 byRegisterAsync, so a crash before commit rolls back user and event together, and after commit theOutboxProcessorguarantees 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 aroundExternalLoginCoreAsync(:226-339), which resolves the caller into exactly one of three paths:- Known external identity (
:235-238), a tracked lookup byLoginProviderandProviderKey. Found means log in, subject only to the lock check below. - Email matches an existing local account (
:269-279). The raw claim is parsed through the value object first,Email.Create(email)(:254), failing closed withError.Validation("Auth.ExternalEmailInvalid", ...)(:255-261) rather than taking.Valuedirectly:Result<T>.Valueisnullon 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 toTryLinkProviderToExistingAccountAsync(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 (IsExternalLoginisLoginProvider is not null); the provider-side takeover guard,externalLoginEmailVerifier.IsCurrentExternalLoginEmailVerifiedAsync()(:372-373), refusingError.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) wheneverexistingUser.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 doesexistingUser.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. - Brand new user (
:280-303). Unlike the link path, account creation is not gated on the verified-email assertion:emailVerifiedis captured from the sameIsCurrentExternalLoginEmailVerifiedAsync()call (:286-287) and passed straight intoUser.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 noemail_verifiedclaim); 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 theUserRegisteredevent (: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, raiseUserRegisteredwith the capturedemailVerifiedflag and save again (:326-331), the same post-identity pattern as local registration; and finally hand off to the base'sIssueTokensAsync(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'sOAuthControllerBase, 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.- Known external identity (
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.Commonrather 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'sIAuthSessionIssuer, whichAddInfrastructureregisters, so the token lifetimes and the cap are configuration (theJwtandRefreshSessionssections) 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 optionalTimeProviderthat only the terms stamp inCreateUseruses. 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. TheUserRegisteredintegration 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). TheEmailVerifiedflag 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 carryspeaker_id, and the claim appears on the next refresh once Conference has publishedSpeakerLinkedToUserback.Where it's used: registered as the
IAuthenticationServiceimplementation by the Application-layerDependencyInjection(MMCA.ADC.Identity.Application/DependencyInjection.cs:36); driven byAuthControllerfor local credentials andOAuthControllerfor the social paths.Caveats / not-in-source: whether
email_verifiedis 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 aPostConfigure<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;IAuthenticationServicewithAuthenticationService,AuthenticationValidators,ISoftDeletedUserValidatorwith the framework's genericSoftDeletedUserValidator<TUser>closed overUser,IAttendeeQueryServicewithAttendeeQueryService, andIUserAdministrationService<UserAdminDTO>withUserAdministrationService, andILegalAcceptanceServicewithLegalAcceptanceService; theIUserDataExportSectioncontributorsEngagementUserDataExportSectionandNotificationUserDataExportSection; theAddUserDataExportSection<TSection>andScanModuleApplicationServices<TAssemblyMarker>helpers fromMMCA.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,GetUserAvatarHandlerfor 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 (forIUserAdministrationService<UserAdminDTO>) are neither a handler, a mapper, nor a validator so the scan would never find them, are registered by hand.TryAddScopedrather thanAddScopedis the load-bearing detail: a host that has already registered an override keeps it, and the module does not clobber it. (The gRPC swap forIAttendeeQueryServicegoes further and usesReplace, 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].ISoftDeletedUserValidatoris a framework contract used by the sharedSoftDeletedUserMiddlewareto 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 genericSoftDeletedUserValidator<TUser>over its ownUsertype. 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 anIUserDataExportSectionand the export handler fans out over the wholeIEnumerable<>. Here Identity registers the two peers' sections in order,EngagementUserDataExportSectionthenNotificationUserDataExportSection, 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 ordinaryIQueryHandler. On the framework side,AddUserDataExportSection<TSection>registers the concrete type withTryAddScopedand appends the interface mapping withTryAddEnumerable, 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 anextension(IServiceCollection services)block (:24). Body order (:34-60):_ = applicationSettings;discards the parameter with a comment marking it reserved for future decorator configuration; thenIAuthenticationServicetoAuthenticationService(:36), theAuthenticationValidatorsparameter object (:37),ISoftDeletedUserValidatortoSoftDeletedUserValidator<User>(:38),IAttendeeQueryServicetoAttendeeQueryService(:39),IUserAdministrationService<UserAdminDTO>toUserAdministrationService(:43), the framework's opt-in Admin/Users endpoint backing (ADR-116), andILegalAcceptanceServicetoLegalAcceptanceService(:47), which backs the framework'sUsers/me/legal-acceptanceendpoints (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), andreturn services(:60). TheAuthenticationServiceSettingsfacade that used to sit betweenAuthenticationValidatorsandISoftDeletedUserValidatorwas deleted on 2026-09-23:AuthenticationServicenow takes its twoIOptions<>bundles directly and DI resolves them without a module-side registration, sinceIOptions<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 theAuthenticationServiceSettingsregistration 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 ordinaryIOptions<T>pipeline instead of a module-registered holder. - Where it's used: called by the API-layer
DependencyInjection'sAddIdentityModule(MMCA.ADC.Identity.API/DependencyInjection.cs:28), whichIdentityModulecalls in turn. - Caveats / not-in-source: whether
applicationSettingswill ever be consumed isNot 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
IModuleSeederthat 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-levelIdentityModuleDbSeeder. - Depends on:
IModuleSeeder;IConfigurationandIServiceProvider(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.comwithAdmin123!among them, atMMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:34-39, with an explicit security notice at:21-25), so seeding is opt-in viaSeeding:IncludeSampleUsers(IdentityModuleSeeder.cs:29) and the method returns immediately when it is false (:29-30).GetValue<bool>defaults tofalsewhen 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'sShouldSeed(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) resolvesIConfiguration(:22), evaluates the gate (:28-30), then resolvesIUnitOfWork(:32) andIPasswordHasher(:33) withGetRequiredService(a missing registration fails loudly at startup rather than silently skipping the seed), constructsnew IdentityModuleDbSeeder(unitOfWork, passwordHasher)(:34), and awaits itsSeedAsync(:35).IPasswordHasheris 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.SeedAsyncruns 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*DbSeederkeeps the API assembly free of persistence detail and leaves the insert logic independently testable. - Where it's used: discovered through the
IModuleSeederinterface 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:IncludeSampleUsersis 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
AssemblyReferencein 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 throughtypeof(AssemblyReference).Assemblyrather 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 oftypeof(UserConfiguration).Assembly, so renaming or moving a real type never silently breaks the scan. - Walkthrough: two
public static readonlyfields, 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.Emptyfallback 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 howUserConfigurationis 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/SourceorMMCA.ADC/Teststoday: 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 isNot 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
AssemblyReferenceand 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 (typicallyClassReference)" (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 throughtypeof(ClassReference).Assembly(the assembly-typed overload the generic one delegates to is atMMCA.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
AssemblyReferenceimpossible 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:51andMMCA.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
Assemblyand itsAssemblyNamestring. 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.Reflectiononly (BCL, imported atAssemblyReference.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.
Assemblyis initialized fromtypeof(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 theirnamespaceline (:3).[Rubric §15, Best Practices & Code Quality](assesses idiomatic, low-ceremony conventions): atypeofhandle survives a project rename, anAssembly.Load("MMCA.ADC.Identity.Application")string does not. - Walkthrough: two
public static readonlyfields,Assembly(:7) andAssemblyName = Assembly.GetName().Name ?? string.Empty(:8). The null-coalescing guard is there becauseAssemblyName.Nameis 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
ClassReferenceas 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 fromtypeof(T).Assembly, so each layer ships its ownClassReferenceto 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 beyondobject, no members. - Concept introduced: cross-reference
AssemblyReferenceabove. The two solve the same problem from opposite directions:AssemblyReferencehands out anAssemblyvalue,ClassReferencehands out a type that a generic method can turn into one without the caller ever mentioningSystem.Reflection. - Walkthrough: the whole declaration is one line (
AssemblyReference.cs:11), sharing the file with itsAssemblyReferencesibling. It is deliberately neitherstaticnorsealed: 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 anAssemblyargument, 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 toScanModuleApplicationServices<TAssemblyMarker>inMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:28-30, a one-line generic wrapper that forwardstypeof(TAssemblyMarker).Assemblyto theAssembly-typed overload declared at:46. SeeDependencyInjectionfor the Application layer. - Caveats / not-in-source: whether the API-layer copy has an active consumer is
Not determinable from sourcewithin 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?orint. - 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 carriesPageNumber/PageSizeandSortColumn/SortDirectionas 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 inGetUsersHandler. - Walkthrough: a positional record with eight parameters (
:12-20): the four nullable filtersEmail/FirstName/LastName/Role(:13-16), thenPageNumber = 1(:17),PageSize = 10(:18), and the nullableSortColumn = 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 ofCreatedOnand a default direction ofdesc, 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 recordis 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-validatespageNumberandpageSizeat the boundary with[Range(1, int.MaxValue)](:146-147). Handled byGetUsersHandler.
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
stringandStringComparisononly. - 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, afterTrim(), ends with the privateEmailSuffix("@" + EmailDomain,:25) underStringComparison.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.
AuthControllerreturns201 Createdstraight 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).AttendeeQueryServicefilters 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 bySyntheticAccountsTestsunderMMCA.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, andContentEditor, with case-insensitive parsing, validation, and equality. - Depends on:
RoleValue(the shared base),RoleNames(the string constants),ResultandError; 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 baseRoleValuesupplies 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: anenum. TheUseraggregate storesRoleas astring(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 aRoleNamesconstant so the domain and the auth layer cannot drift apart. TheContentEditordoc (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), aFrozenDictionary<string, UserRole>built once by the base'sBuildLookup(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 returnsResult.Success, a miss returnsError.Invariantwith the codeUser.Role.Invalid.role ?? string.Empty(UserRole.cs:53) means a null argument is a clean validation failure, not aNullReferenceException.IsValid(string role)(UserRole.cs:66) is the boolean form used byUserInvariants.EnsureRoleIsValid, with the same null-to-empty coalesce.IsOrganizer(string? role)(UserRole.cs:76) compares withStringComparison.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 againstOrganizer.Valuedirectly 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), plusToString()returning the canonical string form (UserRole.cs:94).
- The three static instances (
- Why it's built this way: a
FromStringreturningResult<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); byUser's EF constructor default and byCreateExternal, both writingUserRole.Attendee.Value(User.cs:180,:218); byAuthenticationServicefor 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) andExportUserDataHandler(.../UseCases/ExportUserData/ExportUserDataHandler.cs:38). - Caveats / not-in-source: there is no implicit
stringconversion on this type, so callers that need the persisted form write.Valueexplicitly (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
Useraggregate: the field length constants plus oneEnsure...method per invariant, each returning aResult. - 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 withResult.Combineso the caller gets every violation at once instead of the first one.User.Createis the worked example, combining six checks in a single call (User.cs:225-232). - Walkthrough
- Five public constants (
UserInvariants.cs:12-24):FirstNameMaxLengthandLastNameMaxLengthat 100,EmailMaxLengthat 100,DeviceFieldMaxLengthat 256, andTermsVersionMaxLengthat 32 (:24). They arepublic constfor a reason: the Application-layerRegisterRequestValidatorreuses 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 (UserConfigurationatMMCA.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: itResult.Combines a non-empty check (User.TermsVersion.Empty) with a max-length check againstTermsVersionMaxLength(User.TermsVersion.TooLong), so the value fits its column.User.AcceptTermsis 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 byMailAddress.TryCreate, returningUser.Email.InvalidFormaton 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) eachResult.Combinea non-empty check with a max-length check, so both problems surface together.EnsurePasswordHashIsValid/EnsurePasswordSaltIsValid(UserInvariants.cs:70-74) delegate toCommonInvariants.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 toUserRole.IsValidand returnsUser.Role.Invalidon a miss.EnsurePreferredCultureIsValidandEnsurePreferredThemeIsValid(UserInvariants.cs:91-108) forward to the sharedCommonInvariantsequivalents, passing ADC's own error codes and messages. The allowlist ofSupportedCultures(ADR-027) and the light/dark theme set (ADR-028) live in the framework; only the wording is app-specific.
- Five public constants (
- Why it's built this way: every method takes a
sourcestring that becomes the error'ssourcefield, 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), andUser.AcceptTerms(User.cs:316); the length constants are reused byRegisterRequestValidatorandUserConfiguration. - Caveats / not-in-source:
EnsureEmailIsValidhas no callers in current ADC source. The aggregate validates its address through theEmailvalue object's ownCreateinstead (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 extendsIAnonymizable),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 theIAuditedEntitymarker 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 onIPasswordChangeableUser(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:49), the erasure workflow constrains onIErasableUser, andSendEmailConfirmationHandlerBase<TUser, TCommand>constrains onIEmailConfirmableUser(added for ADR-116).ILegalAcceptingUser(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ILegalAcceptingUser.cs:21) is the Terms of Service capability: read-onlyAcceptedTermsVersionandTermsAcceptedOnplus an idempotent accept operation thatUser.AcceptTermsimplements. ListingIErasableUseron this type directly is load-bearing becauseDeletehere hides the base soft-delete withnew(User.cs:485); only re-declaring the interface onUserre-maps the interface slot onto this type's own member, which is what keeps theUserDeleteddomain 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), andLastModifiedOn/Byalone 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 theUserRegisteredtwo-save dance: the id does not exist until after the insert, which theCreatedoc 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).Emailis anEmailvalue object, not a string, and is marked[Pii]along withFirstName,LastName, andAvatarUrl; the[Pii]marks are what let the privacy tooling find personal columns (ADR-005).PasswordHashandPasswordSaltarebyte[]mapped tovarbinary(max), inside a scopedCA1819suppression that names EF mapping as the reason.Roleis astringfor EF and JWT round-tripping whileUserRoleowns the rules.LinkedSpeakerIdis a nullableSpeakerIdentifierTypescalar, the 1:1 bidirectional counterpart ofSpeaker.LinkedUserId(BR-208).LockedOn(User.cs:127) records when an administrator locked the account, ornullwhile usable; a locked account keeps every row it owns (bookmarks, feedback, points) and can be unlocked, which separates it from the irreversibleAnonymize.AcceptedTermsVersion(User.cs:138) andTermsAcceptedOn(User.cs:144) hold the Terms of Service version this user last accepted and when, bothnullfor 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, soAnonymizekeeps them. Every setter isprivate: 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
RefreshSessionstable (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 fromLockedOnrather 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); andFullName(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, includingRole = UserRole.Attendee.Value(:163), and a private full one used by the factories. Create(User.cs:216-244): parses the email, thenResult.Combines the email result with fiveUserInvariantschecks, returnsResult.Failure<User>(result.Errors)on any failure, and otherwise constructs withId = default.CreateExternal(User.cs:264-292): the OAuth path. It validates email and names only, setsPasswordHash/PasswordSaltto empty arrays and forcesUserRole.Attendee.Value, and recordsLoginProvider/ProviderKey. A newemailVerifiedparameter (defaultfalse,:253) setsIsEmailConfirmedfrom 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): setsIsEmailConfirmed = trueand 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 throughUserInvariants.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 setsAcceptedTermsVersionandTermsAcceptedOn(:327-328). Read byLegalAcceptanceService(LegalAcceptanceService.cs:39,:66).- Behavior:
LinkExternalProvider(User.cs:342-347) attaches a provider to an existing local account and also setsIsEmailConfirmed = 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;UpdateDeviceMetadatasets the seven MAUI fields in one call;SetAvatarUrlrecords 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():Lockis idempotent viaLockedOn ??= utcNow, so locking an already-locked account keeps the original instant rather than resetting it, preserving the audit trail's "when the lock began".UnlocksetsLockedOn = nulland is a no-op on an account that is not locked.ChangeRole(string role)(BR-41, BR-45): validates throughUserInvariants.EnsureRoleIsValid, then assigns the role's canonical spelling viaUserRole.FromString(role).Value!.Valuerather 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 raisesUserPasswordChangedviaAddDomainEvent.Delete: thenewhiding method. It callsbase.Delete()and raisesUserDeletedonly 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 endsUser.cs:549): the erasure half. It builds the placeholder addressdeleted-{Id}@anonymized.invalidwithCultureInfo.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 resetsIsEmailConfirmed = false(User.cs:542): the placeholder address is not reachable and was never proven, so the flag cannot keep asserting that it was.AcceptedTermsVersionandTermsAcceptedOnare 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.
- State (
- Why it's built this way:
DeleteandAnonymizeare separate operations, and the doc onAnonymizesays 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).IsEmailConfirmedand 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:RequireConfirmedEmailis 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 byUserDTOMapper, driven byAuthenticationServiceand the Users use cases (ChangePasswordHandler,ChangePreferencesHandler,DeleteUserHandler,ExportUserDataHandler,SetUserAvatarHandler,RemoveUserAvatarHandler,SendEmailConfirmationHandler, and the reset-password path behindPasswordResetController), mutated on the cross-context link bySpeakerLinkedToUserHandlerandSpeakerUnlinkedFromUserHandler, and read byGetUsersHandler. - Caveats / not-in-source:
AnonymizeleavesRole,PreferredCulture,PreferredTheme, andLinkedSpeakerIduntouched, 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
Userrows intoUserListDTOentirely at the database level, then returns aPagedCollectionResult<T>wrapped in aResult. - Depends on:
IQueryHandler<in TQuery, TResult>(implemented);IUnitOfWork;IQueryableExecutor;IReadRepository<TEntity, TIdentifierType>(obtained viaGetReadRepository);PagingMath;User;UserListDTO;Email;PaginationMetadata;GetUsersQuery; BCLSystem.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 ontoIQueryableand 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 explicitSelectprojects exactly seven columns, includingLockedOn, 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) andUserListDTO'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 withOFFSET/FETCHa row can appear on two consecutive pages or on neither.ApplySortingtherefore appendsThenBy/ThenByDescendingon 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.Emailis theEmailvalue object persisted through a value converter, and a provider can compare that column only against anotherEmail, never against a fragment of one: aContainsover a converted property has no SQL translation. The email filter is therefore whole-address equality, not a substring search, and the remark at:71-78says so explicitly. Two consequences follow and both are handled in code. Matching is case-insensitive for free, becauseEmail.Createnormalizes to lowercase on both the stored and the supplied side. And an input that is not a valid address short-circuits toWhere(_ => 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 byu.Email, the value object itself, notu.Email.Value(:130), while the projection usesu.Email.Value(:51) because aSelectmaterializes through the converter rather than translating a member access. - Walkthrough
- Primary-constructor injection of
IUnitOfWorkandIQueryableExecutor(:17-19), implementingIQueryHandler<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,takeisMath.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 along(:40), and an offset pastint.MaxValuereturns(0, 0)(:42), which materializes the empty page that page genuinely holds instead of a negativeSkip. - Base query (
:31-36):unitOfWork.GetReadRepository<User, UserIdentifierType>()yields the read repository (:31),repository.TableNoTracking(:35) is the untrackedIQueryable<User>, andApplyFilterscomposes theWhereclauses (:36). - Count, then sort, then page (
:39-59):queryableExecutor.CountAsync(baseQuery, cancellationToken)issues aSELECT COUNTagainst the filtered but unpaged query (:39);ApplySortingadds theORDER BY(:42); thenSkip(skip).Take(take)plus theSelectintoUserListDTO, now includingLockedOn = u.LockedOn(:45-57), becomeOFFSET/FETCHwith an explicit column list, materialized byqueryableExecutor.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 asResult.Success(new PagedCollectionResult<UserListDTO>(paged, metadata)). ApplyFilters(:79-97): oneWhereper non-null filter.Emailruns throughEmail.Createand compares whole value objects on success (:81-86);FirstNameandLastNameuseContainson plain string columns (:89-92);Rolecompares thestringcolumn to the query'sstring?directly (:93-94), which works becauseUserstores the role as astring(MMCA.ADC.Identity.Domain/Users/User.cs:71), so no value-object unwrapping is needed here.ApplySorting(:99-111): the direction is descending whenSortDirectionequals"desc"case-insensitively or is null or whitespace (:101-102), which is how the documenteddescdefault is realized. It delegates the key choice toSelectSortKey(:104), then appends theIdtie-break (:106-110).SelectSortKey(:113-136): a private static helper, factored out ofApplySortingso 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. AswitchonsortColumn?.ToUpperInvariant()matches"USERID"or"ID"tou => u.Id(:127), documented by the comment above it (:123-126) as needed because the grid's first column binds toUserListDTO.UserIdand 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 theCreatedOndefault 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 toCreatedOn(:135).
- Primary-constructor injection of
- Why it's built this way: routing count, sort, page and projection through
IQueryableExecutorkeeps 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 toUsercannot leak it through this endpoint without someone editing theSelect. Delegating the cap to the sharedPagingMathrather than open-codingMath.Minmeans the hostile inputs (page 0, negative size, a page number nearint.MaxValue) are handled the same way in every module. Naming every sortable column explicitly inSelectSortKey, 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 toCreatedOn. - Where it's used: dispatched by the organizer Users endpoint on
UsersController(MMCA.ADC.Identity.API/Controllers/UsersController.cs:153), gated byIdentityPermissions.UsersRead(:139). Its page ofUserListDTOfeeds the user-management grid inUserList.
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 theIServiceCollectionunchanged (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/DependencyInjection.cs:20). - Depends on:
Microsoft.Extensions.DependencyInjection.IServiceCollection(NuGet, imported atDependencyInjection.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 anAddModule{Name}{Layer}()method that reads like a built-inIServiceCollectionAPI.[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) exposingpublic 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, andModuleApplicationDbContext,IdentityModuleDbSeederandUserConfiguration. - 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(...)atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:29, betweenAddModuleIdentityApplication(applicationSettings)(:29) andAddModuleIdentityAPI()(:31). That composite is called in turn byIdentityModule.Register(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModule.cs:24), theIModulecontract from G14 (IdentityModule.cs:13). - Caveats / not-in-source: the Identity module ships one
DependencyInjectionclass 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 baredependencyinjectionanchor, 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, andContentEditor(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. ReferencingRoleNames.Organizerinstead of"Organizer"moves that class of bug to compile time.ContentEditoris 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 stringfields.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:
constrather thanstatic readonlyso 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). BCLIReadOnlyList<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'sSharedproject, 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'sAdministrationPermissions.ManageUsersbecauseUsersAdminControllerBasegates its actions on that exact capability string, that it stays OUT ofAllbecauseAllis the list of capabilities ADC itself defines, thatIdentityPermissionGrantsgrants 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 ofAll, 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 onlyUsersRead(:51-52), documented (:44-49) as every Identity capability ADC itself defines, withUsersManageandRolesManagedeliberately excluded because they are the framework's constants and are granted by name inIdentityPermissionGrantsinstead. - Why it's built this way: a
namespace:resource:actionstring convention keeps permissions self-describing and greppable. SplittingAll(ADC's own capabilities) from the two aliased framework constants keeps the catalog honest about provenance: a reader scanningAllsees only what this module coined, while the grant that actually assigns roles is the single place that decides which framework capabilities Organizer also needs. AliasingAdministrationPermissions.ManageUsers/.ManageRolesrather 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), byAdminRolesControllerand byUsersAdminController; granted inIdentityPermissionGrants.Applythroughpermissions.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
IdentityPermissionscapability, plus the two framework administration capabilities the shipped controller bases gate on, toRoleNames.Organizerand to no other role. - Depends on:
RoleNames;IdentityPermissions;PermissionRegistryBuilder(the framework's grant-accumulation type, fromMMCA.Common.Application). - Concept: cross-reference
IdentityPermissionsfor the permission-based authorization model this grant populates and for whyUsersManage/RolesManageare named here rather than folded intoIdentityPermissions.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 byArgumentNullException.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 thatUsersManageandRolesManageare the framework's own constants (AdministrationPermissions), not ADC's, soAllstays 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
Applymethod, rather than inline calls at the module'sDependencyInjection, 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 nousingdirectives 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 thatUserIdis 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 returns204 No Contenton success (:120,:133). Handled byRemoveUserAvatarHandler, which returns a bareResultwith 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
Useraggregate 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 suppliesICommandHandler<RemoveUserAvatarCommand, Result>);MutationContext;IUnitOfWork;IInternalCommandScheduler(the framework's durable internal-command queue);DeleteAvatarBlobInternalCommand(the scheduled command this handler enqueues, handled byDeleteAvatarBlobInternalCommandHandler);SetUserAvatarHandler(it calls that class'sinternal staticTryGetBlobName, imported atRemoveUserAvatarHandler.cs:3, which is why it sits one level higher than its sibling);User;Result;Microsoft.Extensions.Loggingwith[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, theLogMutatedcall and the post-commit hook while still answering success (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutationContext.cs:44-49and the check atMMCA.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 (seeRemoveUserAvatarCommand).[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 ispartial, 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.DeleteAsyncdirectly after the commit; it now schedules aDeleteAvatarBlobInternalCommandthroughIInternalCommandSchedulerinstead (: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 baretry/awaitcannot, 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 throughLogAvatarBlobScheduleFailed(: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, anIInternalCommandSchedulerand anILogger<RemoveUserAvatarHandler>(RemoveUserAvatarHandler.cs:18-21), with the unit of work forwarded toMutateEntityHandlerBase<RemoveUserAvatarCommand, User, UserIdentifierType>(:22). Note the base without a payload type parameter: this command answers with a bareResult. One constant,private const string BlobNameKey = "Avatar.BlobName"(:25), the context key the orphaned blob's name travels under. EntityId(:28) returnscommand.UserId, so the base loads that aggregate and answersError.NotFounditself when the account does not exist.- The mutation (
:31-50): it derives the blob name withSetUserAvatarHandler.TryGetBlobName(entity.AvatarUrl)(:37). When that isnull(no avatar stored, or a URL with no usable final segment) it callscontext.SkipSave()and returnsResult.Success()(:42-43), with the comment at:40-41naming the intent: "an already-satisfied command, not a refused one. No save, no log, no storage call." Otherwise it stashes the name underBlobNameKey(:46) and callsentity.SetAvatarUrl(null)(:47, domain setter atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:394). LogMutated(:53-54) fires the source-generatedLogAvatarRemovedat Information level with the message "User {UserId} avatar removed" (:84-85).OnMutatedAsync(:62-82), post-commit: readscontext.GetOrDefault<string>(BlobNameKey)(:68) and returns immediately when absent (:69-72). Otherwise it schedulesnew DeleteAvatarBlobInternalCommand(blobName)throughinternalCommandScheduler.ScheduleAsync(:74-76), and logsLogAvatarBlobScheduleFailedwhen 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.
- Primary-constructor injection of
- 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'sTryGetBlobNamekeeps 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 inlineawait fileStorage.DeleteAsynctrades 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 asICommandHandler<RemoveUserAvatarCommand, Result>at:34), whose204 No Contentresponse (:133) tells the client the avatar is gone (or was already absent). Unit-tested byMMCA.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 sourcewithin 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
SessionIdentifierTypealias; 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 initmembers,SessionId(UserDataExportBookmarkDTO.cs:10) andCreatedOn, documented as UTC (:13).requiredmeans the producing section cannot forget a field, andinitmakes the row immutable once produced (therequired/initimmutability convention from the primer). - Where it's used: built by
EngagementUserDataExportSectionfrom 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 insideUserDataExportEngagementSectionDTO(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportEngagementSectionDTO.cs:14). The source aggregate on the far side isUserSessionBookmark.
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
UserDataExportBookmarkDTOintroduces, 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 fromUserDataExportPointsEntryDTOrows, 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 arequired string, documented as the readable name of what the check-in attests to (for example"Session");EventId(:14) isrequiredand documented as always set for every scope, which is what makes the row self-locating.SessionId(:17) andSponsorId(:20) are nullableints, 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 arequired DateTimeOffset: unlike the sibling rows, which carryDateTimeand depend on the producer to stamp UTC, this one carries its offset on the wire. - Why it's built this way:
Scopeis astringand not theCheckInScopeenum, 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
EngagementUserDataExportSectionfromUserEngagementCheckInExportDTOrows (EngagementUserDataExportSection.cs:56-65), into theCheckInslist ofUserDataExportEngagementSectionDTO(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
UserNotificationIdentifierTypealias; BCL (DateTime). - Concept: the same export-row shape
UserDataExportBookmarkDTOintroduces ([Rubric §30, Compliance, Privacy and Data Governance]), with one extra nuance: it does include theTitletext, 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), andSentOn(:17, documented UTC) arerequired init. The pair that describes read state is not:IsReadis a plainbool(:20) that defaults tofalse, andReadOnis a nullableDateTime(: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 givesevent:1as 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
NotificationUserDataExportSectionfrom the Notification peer's rows (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/NotificationUserDataExportSection.cs:35-43, withScopeKeycopied straight through at:42) intoUserDataExportNotificationSectionDTO; the underlying entity isUserNotification.
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. ThePointsdoc (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 initmembers (UserDataExportPointsEntryDTO.cs:10-19).ActivityType(:10) is astringcarrying 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), andCreatedOn(:19) the UTC timestamp. - Why it's built this way: same reason as
UserDataExportCheckInDTO'sScope. The producing section mapsp.ActivityType.ToString()off thePointsActivityTypeenum 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
EngagementUserDataExportSectionfromUserEngagementPointsEntryExportDTOrows (EngagementUserDataExportSection.cs:47-55) into thePointsEntrieslist ofUserDataExportEngagementSectionDTO(UserDataExportEngagementSectionDTO.cs:20); the owning aggregate on the far side isPointsEntry.
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
LivePollIdentifierTypeandLivePollOptionIdentifierTypealiases; BCL (DateTime). - Concept: the same export-row shape
UserDataExportBookmarkDTOintroduces ([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 initmembers:LivePollId(UserDataExportPollVoteDTO.cs:10),OptionId(:13), andCreatedOn(:16, documented UTC). - Where it's used: projected by
EngagementUserDataExportSectionfrom the peer'sPollVotesrows (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/EngagementUserDataExportSection.cs:66-71) into thePollVoteslist ofUserDataExportEngagementSectionDTO(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
SessionQuestionIdentifierTypealias; BCL (DateTime). - Concept: the same export-row shape as
UserDataExportBookmarkDTO([Rubric §30, Compliance, Privacy and Data Governance]). It is the upvote counterpart ofUserDataExportSubmittedQuestionDTO: questions the user wrote and questions the user merely endorsed are both personal activity, so they travel as two separate lists. - Walkthrough: two
required initmembers:QuestionId(UserDataExportQuestionUpvoteDTO.cs:10) andCreatedOn(:13, documented UTC). - Where it's used: projected by
EngagementUserDataExportSection(EngagementUserDataExportSection.cs:72-76) into theQuestionUpvoteslist ofUserDataExportEngagementSectionDTO(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
Subjectslot of the framework's export envelope, not the whole document. - Depends on: the
UserIdentifierTypeandSpeakerIdentifierTypealiases; BCL (DateTime). Notably it depends on nothing from MMCA.Common: the envelope types it plugs into declare that slot asobject. - 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. Theremarksblock (:9-15) records the split that ADR-076 introduced: this type is theSubjectofUserDataExportDTOinMMCA.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'sSubjectproperty is typedobjectand serializes by runtime type (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:32-40). - Walkthrough
- Identity account fields (
UserDataExportSubjectDTO.cs:19-34):requiredUserId(:19),Email(:22),FirstName(:25),LastName(:28),FullName(:31), andRole(:34). - External-login fields (
:37,:40):IsExternalLoginand the provider name only; the doc onLoginProvider(:39) restates that the opaque provider key is intentionally omitted. - Cross-context link and profile (
:43,:46): the nullableLinkedSpeakerId(the scalar link to a Conference speaker, never a cross-database FK) andAvatarUrl(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 blockUserListDTOrefuses 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):CreatedOnand nullableLastModifiedOn, both documented UTC, the same audit fields the framework stamps inSaveChangesAsync.ExportUserDataHandlerre-stamps theirDateTimeKindon 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 asKind=Unspecified, which would serialize without the trailingZthe doc promises; the comment at:67-69is the record of that.
- Identity account fields (
- 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
Sharedlets the API, the UI, and the tests name it without referencing the Domain assembly. Together with the erasure path on theUseraggregate (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
ExportUserDataHandlerin one object initializer (ExportUserDataHandler.cs:48-74) and returned as theobject?subject (:76); the shared base then stamps it into the envelope'sSubjectalongside aFormatVersionof"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 byUsersDataExportController, a subclass of the framework'sDataExportControllerBase<TQuery>that supplies only theUsersroute 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), soGET /Users/{userId}/export(:77) returns 404 in a host that has not enabledPrivacyFeatures.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
SessionQuestionIdentifierTypeandSessionIdentifierTypealiases; 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 initmembers,QuestionId(UserDataExportSubmittedQuestionDTO.cs:10),SessionId(:13), andCreatedOnin UTC (:16). - Where it's used: carried in
UserDataExportEngagementSectionDTO'sSubmittedQuestions(UserDataExportEngagementSectionDTO.cs:17), populated byEngagementUserDataExportSection(EngagementUserDataExportSection.cs:41-46); the source aggregate isSessionQuestionin 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
Useraggregate. - Depends on:
MMCA.Common.Shared.Abstractionsfor theServiceContractAttributemarker (IAttendeeQueryService.cs:1), theUserIdentifierTypealias, 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
Sharedassembly.[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 inSharedso 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-processAttendeeQueryServicewhen Identity runs in the same host, theDisabledAttendeeQueryServicestub when the module is switched off, and theAttendeeQueryServiceGrpcAdapterwhen 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 atIAttendeeQueryService.cs:10is not decoration and it is notSystem.ServiceModel: it is the framework's own marker introduced in G13, whose doc records that a dedicatedServiceContractPurityTestsBasefitness rule scans every mapped assembly for types carrying it and fails the build if such a type depends on the producing service'sDomain,Application, orInfrastructurelayers (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 aVersionthat 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) returnsTask<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, anIQueryable) 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 toINotificationRecipientProvider); implemented in-process byAttendeeQueryService, stubbed byDisabledAttendeeQueryService, served over the wire byAttendeesGrpcService, and satisfied remotely byAttendeeQueryServiceGrpcAdapter.
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:managecapability: 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
UserIdentifierTypealias; 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'sremarks(UserAdminDTO.cs:8-13) states directly why this is not more fields bolted ontoUserListDTO: the two DTOs are reached through different permissions,identity:users:readserves the organizer's roster,users:manageserves the administration screens. Device metadata stays out of both (attendee device privacy), and no credential material is ever projected here either, the same restraintUserDataExportSubjectDTOdocuments for the export path. - Walkthrough: nine positional parameters (
UserAdminDTO.cs:23-32), each documented at:14-22:UserId,Email,FirstName,LastName,Role,CreatedOn, a nullableLockedOn,IsExternalLogin, andIsEmailConfirmed(ADR-116). Two computed members read off those without adding wire fields:IsLocked => LockedOn is not null(:35) andFullName => $"{FirstName} {LastName}"(:38). - Why it's built this way: this is the app-owned
TUserDtoclosing the framework's generic administration slice.UserAdministrationServiceimplementsIUserAdministrationService<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(...))afterOrderBy/Skip/Take(UserAdministrationService.cs:72-86), so the excluded columns never leave the database: the same projection-as-privacy-boundary patternUserListDTOuses for its own row.UsersAdminControllercloses 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) andservices.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 throughUsersAdminController'sAdmin/Usersendpoints; consumed client-side asIUserAdminUIService<UserAdminDTO>on theUserDetailpage (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
nullwhen 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. TheCA1054suppression (UserAvatarDTO.cs:5) carries its own justification in source: this is a serialized DTO field, so the URL stays astringon the wire rather than becoming aUri. - Walkthrough: a single positional record,
public sealed record UserAvatarDTO(string? AvatarUrl)(UserAvatarDTO.cs:6), with the parameter documented atUserAvatarDTO.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 byUsersController'sme/avatarendpoints (GETatMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:51returningOk(result.Value)at:65,POSTat:79returning at:115). The delete endpoint (:119) returns a bareActionResultwith a 204 (:133), because there is no avatar state left to report. Client-side it is deserialized byUserServiceinGetMyAvatarUrlAsync(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/UserService.cs:112-115) andUploadMyAvatarAsync(:139-142), both of which immediately flatten it withdto.Map(value => value.AvatarUrl ?? string.Empty)so the page never handles the envelope, only aResult<string>. TheProfilepage stores that string in its_avatarUrlfield (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'sUserDataExportSectionDTOownsAvailableandUnavailableReason(MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:72,:88). This type is only the payload the envelope carries in itsobject? Dataslot (: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-25records that it is reported independently of the points ledger), and the two conference-day live-layer listsPollVotes(:29,UserDataExportPollVoteDTO) andQuestionUpvotes(:32,UserDataExportQuestionUpvoteDTO).IsOnLeaderboard(:35) is a plainboolandLeaderboardDisplayName(: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
Completeresult 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
EngagementUserDataExportSectionfromUserEngagementExportDTO(EngagementUserDataExportSection.cs:34-79) and handed toUserDataExportSectionResult.Complete(SectionName, data)(:81);ExportUserDataHandlerBase<TUser, TQuery>then wraps it into a section envelope inRunSectionAsync(ExportUserDataHandlerBase.cs:166-183) and, on a thrown call, replaces it with anAvailable = falseenvelope 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
UserDataExportEngagementSectionDTOintroduces, 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 itsDbContext, 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 viaUserDataExportSectionResult.Complete(SectionName, data)(:46). It is the second of the two sections ADC registers, and the registration order is the document order:AddUserDataExportSection<EngagementUserDataExportSection>()thenAddUserDataExportSection<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
IAttendeeQueryServicethat 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, theUserIdentifierTypealias; 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.RegisterDisabledStubscloses that hole, andIdentityModuleregisters exactly this type there (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModule.cs:19-20). Crucially the stub lives inShared, 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 isinternal 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'sProgram.cs. - Walkthrough: the whole class is one expression-bodied method.
GetAttendeeUserIdsAsyncreturnsTask.FromResult<IReadOnlyList<UserIdentifierType>>([])(DisabledAttendeeQueryService.cs:10-11): a completed task over an empty collection expression, so there is no allocation-heavy work and noasyncstate 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()callsservices.Replace(ServiceDescriptor.Scoped<IAttendeeQueryService, AttendeeQueryServiceGrpcAdapter>())(MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/DependencyInjection.cs:47), and its doc comment (:25-36) records whyReplacerather thanTryAdd: either the real in-processAttendeeQueryServiceor 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.RegisterDisabledStubsas a singleton (IdentityModule.cs:20); overwritten at startup in the Notification service, whose application-pipeline registration runss => s.AddIdentityAttendeeClient()as its second step (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:230) with the ordering andReplacerationale 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
Useraggregate. - Depends on:
IBaseDTO<TIdentifierType>(the framework DTO contract fromMMCA.Common.Shared.DTOs, imported atUserDTO.cs:1), theUserIdentifierTypealias. - 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). ImplementingIBaseDTO<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: theUserDTOMapperdoc comment (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/DTOs/UserDTOMapper.cs:11) records thatPasswordHash,PasswordSalt, andRefreshTokenare 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 initmembers (UserDTO.cs:11-23),Id(:11),Email(:14, documented as the login credential, BR-200),FirstName(:17),LastName(:20), andRoleas astringused 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:
requiredon every member means the compiler, not a reviewer, catches a mapper that forgets a field, and keeping the type inSharedlets 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) implementingIEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>over (User,UserDTO,UserIdentifierType). The generator fills inMapToDTO(UserDTOMapper.cs:18); the collection overload is hand-written over it (:21-25), and a privateEmailToStringhelper converts theEmailvalue 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
UserIdentifierTypealias;IUserAdminDTO(MMCA.Common.Shared.Auth.Administration, imported atUserListDTO.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: theUseraggregate carriesDeviceId,DeviceModel,DeviceManufacturerand friends, but an organizer browsing the user grid has no business seeing them. Itsremarks(ADR-116) add a second fact on top: this is also the row the framework's shared user-administration list renders, so the type answersIUserAdminDTOby reusingUserId,Email, andRoleas-is, without renaming the wire contract, and maps the lock state explicitly offLockedOn(UserListDTO.cs:14). Because the projection is still built inside the query, a.Select(u => new UserListDTO { ... })translated to SQL afterSkip/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), andRole(:29) arerequired init;CreatedOnis a plaininitDateTime(:32).Roleis astring, not theUserRolevalue object: the wire format stays primitive, and the closed-set type is a domain concern. The nullableLockedOn(:39, documented:34-38) is new: when an administrator locked the account, ornullwhile 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 theAdmin/Usersendpoints.bool IUserAdminDTO.IsLocked => LockedOn is not null;(:42) is an explicit interface implementation, soIsLockedis reachable only through anIUserAdminDTO-typed reference, leaving the record's own public surface unchanged for every existing consumer that namesUserListDTOdirectly. - Why it's built this way: keeping the list DTO separate from
UserDTOlets the grid evolve (sortable columns,CreatedOn, nowLockedOn) 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. ImplementingIUserAdminDTOexplicitly, rather than adding a second public lock-state property, letsUserListDTOalso 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 orUserAdminDTO(which the app-sideAdmin/Usersscreens use instead) through the same shape. - Where it's used: produced by
GetUsersHandlerinside aPagedCollectionResult<T>(GetUsersHandler.cs:62), returned byUsersController's list endpoint (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:138-141,:158), and consumed client-side throughIUserUIService/UserService(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Services/UserService.cs:27,:64,:71-72) as theMudDataGridrow type on theUserListpage, which inheritsDataGridListPageBase<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 withnameof(UserListDTO.Email)and friends (UserList.razor.cs:70-73,:63), so a renamed property breaks at compile time rather than at runtime. Also referenced fromUserAdminDTO's own doc comment (UserAdminDTO.cs:5) and consumed fromSpeakerDetail/SpeakerUserSearchin 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: BCLSystem.Globalization.CultureInfo(UserDetail's culture-invariant interpolation) andSystem.Uri(RoleEdit'sUri.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 inIdentityUIModulereferenceIdentityRoutePaths.Profile,.Usersand.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 stringfields on apublic static class(:6):Users = "/users"(:8),Profile = "/profile"(:9),Roles = "/roles"(:13, ADR-116 role-administration roster) andConfirmEmail = "/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}, andRoleEdit(string role)(:29-30), which interpolates{Roles}/{Uri.EscapeDataString(role ?? string.Empty)}. The doc comment onRoleEdit(: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
@pagedirectives onUserList,Profile,RoleListandRoleEditstill spell their route literally, because a Razor@pagedirective requires a compile-timeconst, and these arestatic readonlyfields, notconst. 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'sNavItems, the Roles tests (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleListTests.cs,RoleEditTests.cs), andUserDetail'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
ComponentBaseand 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 receivesRolefrom. - 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), whichRoleListlinks to per row. Exercised byMMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleEditTests.csand cross-checked for authorization byMMCA.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 thepartialsplit from the.razormarkup. - Where it's used: reached via
IdentityRoutePaths.Roles, linked fromIdentityUIModule's "Roles" nav item. Exercised byMMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.UI.Tests/Pages/Roles/RoleListTests.csandIdentityRouteAuthorizationTests.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:ChangePasswordCommandis(UserIdentifierType UserId, ChangePasswordRequest Request)and additionally marks itselfICacheInvalidatingandIUserScopedCommand<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).Requestis the whole payload, so the marker interface is the load-bearing part of the declaration. Because the record implementsICommandWithRequest<ForgotPasswordRequest>, the module scan auto-registersIValidator<ForgotPasswordCommand>as aCommandRequestValidator<TCommand, TRequest>that delegates to whateverIValidator<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 byAddValidatorsFromAssemblyContaining<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 itsResetPasswordCommandICacheInvalidatingand this one deliberately carries no marker at all, because starting a reset changes no cached state. - Where it's used: built by
PasswordResetControllerin its one-line factory overrideCreateForgotPasswordCommand(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:36) forPOST Auth/forgot-password, and handled byForgotPasswordHandler, which the controller receives asICommandHandler<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
UserIdplus 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 BCLSystem.ReadOnlyMemory<byte>for the payload andSystem.TimeSpanfor 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:Contentis aReadOnlyMemory<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]: implementingIHasTimeoutgives 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).UserIdis controller-stamped from the authenticated principal and never client-supplied;Contentis 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
IHasTimeoutdirectly 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 theAvatar.InvalidUploadvalidation error (:92-98). Note the two distinct limits:MaxAvatarBytes = 2 * 1024 * 1024is the image budget (BR-116a, 2 MB,:39), whileMaxAvatarRequestBytes = 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 friendlyAvatar.InvalidUploadcheck instead of by a bare Kestrel 413 (:42-47). The stream is then buffered into abyte[](:100-107). Handled bySetUserAvatarHandler, which returns aUserAvatarDTOcarrying the new URL, and pre-screened bySetUserAvatarCommandValidator.
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
ConfirmEmailRequestwire payload. - Depends on:
ConfirmEmailRequest(the shared request DTO),ICommandWithRequest<ConfirmEmailRequest>(the marker that routes it through the shared request-validator convention, the same mechanismForgotPasswordCommanduses). - Concept: the same anonymous-command shape
ForgotPasswordCommandteaches: 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;Requestis the whole payload. - Why it's built this way: routing through
ICommandWithRequest<TRequest>gets the command an auto-registeredIValidator<ConfirmEmailCommand>for free viaCommandRequestValidator<TCommand, TRequest>, the same conventionForgotPasswordCommandrelies on, so ADC supplies no bespoke validator for this command. - Where it's used: constructed by
EmailConfirmationControllerand handled byConfirmEmailHandler. Covered byMMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.API.Tests/Controllers/EmailConfirmationControllerTests.csandMMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ConfirmEmail/ConfirmEmailHandlerTests.cs, and pinned byCommandValidatorCoverageTests.
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),ExternalAuthExtensionsfor theExternalLoginscheme name; externals: ASP.NET Core (IHttpContextAccessor,HttpContext.AuthenticateAsync,ClaimsPrincipal.FindFirst), BCLbool.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 atMMCA.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 isfalse. - Walkthrough: a primary-constructor class taking one dependency (
HttpContextExternalLoginEmailVerifier.cs:17-18) and exposing one method.EmailVerifiedClaimType(HttpContextExternalLoginEmailVerifier.cs:21), theinternal const string "email_verified".internalrather thanprivateso the claim name is nameable from the test assembly instead of being re-typed as a literal.IsCurrentExternalLoginEmailVerifiedAsync()(HttpContextExternalLoginEmailVerifier.cs:24-36). It readshttpContextAccessor.HttpContextand returnsfalsewhen there is none (:26-30), so a call outside a request reports unverified instead of throwing.- It then re-authenticates the short-lived
ExternalLogincookie (:32,ExternalAuthExtensions.ExternalLoginScheme, the constant atMMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:28). That is the same principalOAuthControllerBase.CompleteAsyncjust 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 parseabletrueall yieldfalse, viaauthenticateResult.Principal?.FindFirst(...)?.Valueplusbool.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 v3email_verifiedkey and the legacy v2verified_emailkey onto the singleemail_verifiedclaim (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:209-213), and a secondPostConfiguredoes the same for Apple, whoseid_tokenassertsemail_verifiedfor 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 isservices.TryAddScoped<IExternalLoginEmailVerifier, HttpContextExternalLoginEmailVerifier>()immediately afterAddHttpContextAccessor()inAddModuleIdentityAPI(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 returnsError.Unauthorizedwith the codeAuth.ExternalEmailNotVerifiedand a message steering the user back to the local credential flow (AuthenticationService.cs:248-254); only a verified answer reachesexistingUser.LinkExternalProvider(...)(AuthenticationService.cs:398). TheOAuthControllerendpoints 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);ImageContentSnifferfromMMCA.Common.Application(imported atSetUserAvatarCommandValidator.cs:2); the FluentValidationAbstractValidator<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 byValidatingCommandDecorator<TCommand, TResult>, which resolves everyIValidator<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 domainErrorviaValidationFailureExtensions.ToErrors(:82, implementation atMMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ValidationFailureExtensions.cs:19-21), which is what carries theWithErrorCode(...)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 inCommandValidatorCoverageTests, 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 onUsersController(.../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 codeAvatar.Content.Required. - Rule 2, size (
:29-32):content.Length <= MaxAvatarBytes, error codeAvatar.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 codeAvatar.UnsupportedFormat, the same codeSetUserAvatarHandleremits 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 theAvatar.UnsupportedFormatcontract 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:58and resolved by the validating decorator as anIValidator<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),NavItemandNavSectionfromMMCA.Common.UI.Common(:3),IdentityRoutePaths,RoleNamesandAdministrationPermissionsfromMMCA.Common.Shared.Auth(:2); externals: MudBlazorIcons.Material.Filled(:5), BCLSystem.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). TheIUIModulecontract is introduced in G15: a host collects every registered implementation, merges theirNavItemsinto the sidebar, and passes theirAssemblyto 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-initializedIReadOnlyList<NavItem>with three entries. "My Profile" points atIdentityRoutePaths.Profilewith aPersonicon inNavSection.Userand no required role, so every signed-in user sees it (:19). "Users" points atIdentityRoutePaths.Userswith aSupervisedUserCircleicon, passesRoleNames.Organizeras theRequiredRolepositional argument, and sits inNavSection.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 onUsersController, whose list endpoint demandsIdentityPermissions.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 atIdentityRoutePaths.Roleswith anAdminPanelSettingsicon, and is gated onRequiredPermission: AdministrationPermissions.ManageRolesinstead ofRequiredRole. 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 thepermissionclaim the Identity host mints from its registry. - Every entry passes
typeof(IdentityUIModule)as theTitleResourceargument, which per theNavItemcontract 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-neededTermsAcceptanceGate(fromMMCA.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
ManageRolesto. - Where it's used: passed as the type argument to the framework's
AddUIModule<TModule>()insideDependencyInjection.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>), atMMCA.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). LikeForgotPasswordHandler, 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
ForgotPasswordHandlerteaches: 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/TCommandtype arguments. This mirrorsForgotPasswordHandler's split, except that here even the one hookForgotPasswordHandlerBaserequires (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 intoEmailConfirmationController. Covered byMMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ConfirmEmail/ConfirmEmailHandlerTests.csand the framework's ownMMCA.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
ConfirmEmailHandlerBasein 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 itsIdentityUIModuledescriptor into any Blazor host. - Depends on:
IdentityUIModule,IUserUIService/UserServicefromMMCA.ADC.Identity.UI.Services,UserAdminDTO(the DTO the user-administration client closes over), and the framework'sAddUIModule<TModule>(),AddUserAdministrationUI<TUserAdminDTO>()andAddRoleAdministrationUI()extensions fromMMCA.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 classicpublic static IServiceCollection AddX(this IServiceCollection services), the method lives inside a C# previewextension(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 ordinaryservices.AddIdentityUI(). - Walkthrough:
AddIdentityUI()(DependencyInjection.cs:20-42) does three things and returnsservicesfor chaining (:41).services.AddUIModule<IdentityUIModule>()(:24), the shared two-step prologue every module'sAdd{Module}UI()opens with, with the intent restated in the comment above it (:22-23). InsideMMCA.Common.UIthat single call runs the Scrutor scan over the descriptor's assembly, registering everyIEntityService<TEntityDTO, TIdentifierType>implementationAsImplementedInterfaceswith a scoped lifetime, then registers the descriptor withAddSingleton<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 onusers:manage. The comment above it (:26-30) is explicit that this is closed over ADC's ownUserAdminDTO, 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 onroles: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 anIEntityService, so the scan cannot pick it up (the same asymmetryIUserUIServicedocuments, because the Users API returnsUserListDTOrather than anIBaseDTO-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 forAddUIModule<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, leavingAddIdentityUI()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), andMMCA.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
DependencyInjectionclass per layer (this UI one plus the API, Application, and Infrastructure copies covered in sibling parts of this chapter). They all slug to the baredependencyinjectionanchor, 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 isResult.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, andPasswordResetRejectedtakes 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,PasswordResetRequestedandPasswordResetEmailFailed(: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]).
- The logging is part of that contract rather than an afterthought. The base declares its own
- 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 callsFirstOrDefaultAsyncwithu => u.Email == emailandasTracking: false(:30-33).- Three details in those four lines. The predicate compares the
Emailvalue object rather than a raw string, so normalization is the value object's job and not a lowercase call here.asTracking: falseis 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 protectedUnitOfWorkproperty, 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'sUserstores 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 lookupAuthenticationServicealready uses for login (ForgotPasswordHandler.cs:16-20, and seeAuthenticationService.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:
IPasswordResetTokenServiceowns the token, its lifetime, and the per-email throttle. The knobs live inPasswordResetSettings, bound from thePasswordResetsection withValidateDataAnnotations().ValidateOnStart()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:160-163), which is what turns a badTokenLifetimeMinutes(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:ResetUrldefaults 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 intoPasswordResetController(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:29), whose inheritedPOST Auth/forgot-passwordaction is[AllowAnonymous],[Idempotent], rate-limited by the auth-ip policy, and answers202 Acceptedon every success (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:75-92). Covered byMMCA.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.
IEmailSenderis 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
Useraggregate, then schedules the previous blob for deletion through the durable internal-command queue (ADR-114). It returns aUserAvatarDTOwith the new URL. - Depends on:
MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload>(base class, which supplies theICommandHandler<in TCommand, TResult>implementation);MutationContext;IUnitOfWork;IImageProcessor;IFileStorageService;IInternalCommandScheduler;DeleteAvatarBlobInternalCommand;ImageContentSniffer;User;UserAvatarDTO;Result;Error; externalsMicrosoft.Extensions.Logging(ILoggerplus[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 atSetUserAvatarHandler.cs:63, implemented atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/ImageContentSniffer.cs:15-16as 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 inMutateCoreAsync(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:277-328): resolve the repository (:280), load the aggregate (:281), answerError.NotFoundstamped 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 callLogMutated(:318) and the post-commitOnMutatedAsync(:319). Subclasses fill in hooks. TheMutationContextis the mechanism that lets one hook hand a value to a later one: aDictionary<string, object?>bag (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutationContext.cs:33) withSet<TValue>(:56), type-checkedTryGet<TValue>(:69) andGetOrDefault<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 overriddenHandleAsyncmust 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 ispartial. - Walkthrough
- Primary-constructor injection of
IUnitOfWork,IImageProcessor,IFileStorageService,IInternalCommandScheduler, anIServiceScopeFactoryand anILogger<SetUserAvatarHandler>(SetUserAvatarHandler.cs:27-33), with the unit of work forwarded to the baseMutateEntityPayloadHandlerBase<SetUserAvatarCommand, User, UserIdentifierType, UserAvatarDTO>(:34). Two constants:internal const int AvatarSize = 256, the canonical edge length (:37), andprivate 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): theasyncHandleAsyncoverride first runsImageContentSniffer.IsAllowedImage(command.Content.Span)(:63) and returnsError.Validationwith 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), awaitsbase.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-writableMemoryStream(:99) and handed toimageProcessor.NormalizeToSquareJpegAsync(content, AvatarSize, ...)inside anawait usingblock (:101-104); a failedResult<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 atMutateEntityHandlerBase.cs:305-306). Nothing has been uploaded yet on this path, so the field staysnulland no cleanup is scheduled. - Upload (
:111-123): a fresh eight-hex-character suffix comes fromGuid.NewGuid().ToString("N")[..8](:111), and the blob name is built culture-invariantly withstring.Create(CultureInfo.InvariantCulture, $"{command.UserId}-{suffix}.jpg")(:112). The JPEG is streamed tofileStorage.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); thenentity.SetAvatarUrl(uploaded.Value!.AbsoluteUri)(:127, domain setter atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:394). The base commits. LogMutated(:133-134) fires the source-generatedLogAvatarSetat 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 readscontext.GetOrDefault<string>(PreviousBlobNameKey)(:150) and returns immediately when it is null (:151-154). Otherwise it schedules aDeleteAvatarBlobInternalCommandthroughinternalCommandScheduler.ScheduleAsync(...)(:158-160) instead of deleting the blob inline; a scheduling failure is logged viaLogAvatarBlobScheduleFailedat 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 ownIInternalCommandSchedulerfrom it (:185), and schedules aDeleteAvatarBlobInternalCommandfor the orphaned new blob withCancellationToken.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 failedResultlogsLogAvatarBlobScheduleFailed(:189-192) and a thrown exception logsLogAvatarBlobScheduleThrewwith the exception (:194-197); neither replaces the original outcome.TryGetBlobName(:200-204): aninternal statichelper that parses the stored URL withUri.TryCreate, requires more than one segment, and returns the unescaped final segment as the blob name (otherwisenull). It is deliberatelyinternal staticbecauseRemoveUserAvatarHandlerreuses it.
- Primary-constructor injection of
- 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 inHandleAsynccovers 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 asICommandHandler<SetUserAvatarCommand, Result<UserAvatarDTO>>at:33); the returnedUserAvatarDTOcarries the new URL back for immediate display. The type is also referenced fromRemoveUserAvatarHandler,DeleteUserHandlerandSetUserAvatarCommandValidator. Unit-tested byMMCA.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 behindIFileStorageService, 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 inSetUserAvatarCommandValidator(:29-32); the comment at:61-62says 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 scheduleDeleteAvatarBlobInternalCommand; 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_uploadedBlobNamefield 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'sAddModuleIdentityApplication(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58,ScanModuleApplicationServices<ClassReference>()) and registered asIValidator<ChangePasswordRequest>by theAddValidatorsFromAssemblycall 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'sCommandRequestValidator<TCommand, TRequest>(MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:30), which doesRuleFor(c => c.Request).SetValidator(validator)(CommandRequestValidator.cs:39) and is closed over any command implementingICommandWithRequest<TRequest>by a reflection loop in the same scan (DependencyInjection.ModuleScanning.cs:121-135).ChangePasswordCommanddeclares 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 codeUser.CurrentPassword.Required(ChangePasswordRequestValidator.cs:15-16). The explicitWithErrorCodematters: 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).Includefolds 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
ChangePasswordHandlervia the framework'sChangePasswordHandlerBase, 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>byCommandRequestValidator<ChangePasswordCommand, ChangePasswordRequest>, which the Validating decorator runs beforeChangePasswordHandlerfor the password-change endpoint onAuthController.
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, theUserIdentifierTypealias. - 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 nullableCurrentUserRole. The handler never reaches forHttpContext; the controller reads the claims once throughICurrentUserServiceand 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. ImplementingIUserOwnedRequestis the load-bearing part: the framework's generic constraint iswhere TQuery : IUserOwnedRequest(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:55), so the sharedUserOwnershipRulecan readUserId,CurrentUserId, andCurrentUserRoleoff 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 noIQueryCacheable, 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
CurrentUserRolemirrors reality at the edge, where a role claim may simply be absent. The privilege check is therefore written to accept null and answerfalserather than to assume a role exists (ExportUserDataHandler.HasExportPrivilege,ExportUserDataHandler.cs:38). - Where it's used: constructed by
UsersDataExportControllerforGET Users/{userId}/export(UsersDataExportController.cs:32-35) and handled byExportUserDataHandler, which the controller receives asIQueryHandler<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:
SelfHttpWarmupTaskBasefromMMCA.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 byAddWarmupReadiness()keeps/health/readynot-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 shapeUserService.GetPagedAsyncbuilds for the organizer list once the empty filter and sort values are dropped, leaving only the paging pair.Name => "SelfHttpWarmup"(SelfHttpWarmupTask.cs:39) andWarmupPaths => Paths(SelfHttpWarmupTask.cs:42), the base's two abstractions.RequireSuccessStatusCode => false(SelfHttpWarmupTask.cs:49), the one genuinely interesting override.UsersControlleris[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 aNoCachebase 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
Useraggregate raises when an account is soft-deleted (BR-56). It carries the user id and nothing else. - Depends on:
BaseDomainEvent, theUserIdentifierTypealias. - 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 secondUserDeletedrecord, inMMCA.ADC.Identity.Shared.Users.IntegrationEvents, carrying the same fact to other processes. ABaseDomainEventsuch as this one is dispatched in-process by the framework's domain-event dispatcher afterSaveChangesAsync(deferred until after commit when a transaction is open), while aBaseIntegrationEventleaves its outbox row unprocessed for theOutboxProcessorto publish to the broker (ADR-003). SameAddDomainEventcall 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 theRefreshSessionstable 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 byDeleteUserHandler; 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, theUserIdentifierTypealias. - 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.ChangePasswordafter 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, theUserIdentifierTypealias. - 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):UserIdandDeletedOn. 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.
DeleteUserHandlercallsuser.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow()))insideOnAfterSoftDeleteAsync, 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 verySaveChangesAsyncthat 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 injectedTimeProvider(DeleteUserHandler.cs:33), notDateTimeOffset.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 domainUserDeleted: 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 theLeaderboardOptIndisplay 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 withx.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,ResultandErrorType, theUserIdentifierTypealias; 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 isIEntityService<TEntityDTO, TIdentifierType>, which assumes a full CRUD resource whose DTO implementsIBaseDTO<TIdentifierType>. The doc comment states plainly why users cannot use it (IUserUIService.cs:7-10): the users API returnsUserListDTO, a projection with noIBaseDTOidentity, 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 behindAddUIModule<IdentityUIModule>()only findsIEntityService<,>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 aResultcarrying the API's own errors with theirErrorTypeintact, so a page branches on the outcome instead of catching, and only the caller's ownOperationCanceledExceptionstill escapes. That single sentence is what removes try/catch from the Identity pages. - Walkthrough: six members.
GetPagedAsync(...)(IUserUIService.cs:22-31) takes nullableemail,firstName,lastName,rolefilters,pageNumber/pageSizedefaulting to 1 and 10, and nullablesortColumn/sortDirection. It returnsResult<(IReadOnlyList<UserListDTO> Items, int TotalItems)>rather than the wire'sPagedCollectionResult<T>, so the page gets exactly the two things aMudDataGridneeds, wrapped in the outcome type.DeleteAsync(UserIdentifierType userId, ...)(IUserUIService.cs:36) returns a bareResult; 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), andRemoveMyAvatarAsync(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 returnsResult<(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 byExportUserDataHandlerand surfaced byLegalAndDataCard.
- Why it's built this way: an interface here is what lets the
ProfileandUserListpages be tested with a fake instead of a live HTTP stack ([Rubric §14, Testability]), and it is the boundary that keepsHttpClient, retries, and JSON shapes out of the components entirely. - Where it's used: implemented by
UserService; injected intoUserList(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:34) andProfile(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor.cs:20); registered scoped inAddIdentityUI(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 stableSectionNamethat appears verbatim in the document a subject reads (IUserDataExportSection.cs:27) and oneExportAsyncper user (:38). Sections accumulate throughAddUserDataExportSection<TSection>(), which registers them scoped and viaTryAddEnumerableso 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) awaitsGetUserNotificationExportAsync(userId, ...)on the peer (:29-31), then projects each row into aUserDataExportNotificationDTOwithNotificationId,Title,SentOn,IsRead,ReadOn, andScopeKey(:35-43), wrapped in aUserDataExportNotificationSectionDTO(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DataExport/UserDataExportNotificationSectionDTO.cs:10).ScopeKeyis 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 byIdentity.Sharedrather 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 aCompleteresult, which is a truthful "nothing here" rather than the ambiguous "could not tell" anUnavailablewould 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 ofExportUserDataHandlerapply 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 withservices.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 byMMCA.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, theUserIdentifierTypealias. - 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 inShared, 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 theEmailvalue object orUserRole, so a subscriber needs no Identity types to deserialize it. It is arecordwith positional members, so adding an optional member later is an additive change consumers can ignore, which is exactly howEmailVerifiedarrived. 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 afalsedefault, 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.Emailis 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;EmailVerifiedis alwaysfalsefor 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 stilldefaultat that point, and the outbox serializes the payload at capture time, so it would persistUserId = 0, which the cross-service Conference consumer cannot resolve (AuthenticationService.cs:31-38). InsteadAuthenticationServicewraps the base registration workflow in a singleUnitOfWork.ExecuteInTransactionAsync(AuthenticationService.cs:81-88), then inOnUserRegisteredAsyncraises 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 thespeaker_idclaim. The same raise happens for brand-new external OAuth users, guarded by anisNewUserflag (AuthenticationService.cs:321-331), and it is that path that can populateEmailVerified = true. - Where it's used: consumed by Conference's
UserRegisteredHandler, which runs the email-match speaker auto-link and publishesSpeakerLinkedToUserback so Identity can setUser.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
AuthenticationServiceAFTER the unit-of-work commit" (UserRegistered.cs:7-9), and theUser.Createremarks repeat the phrasing (User.cs:203-207); neither still describes the mechanism. Current source raises it viaAddDomainEventon 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 atAuthenticationService.cs:31-41. WhetherUserRegisteredHandlercurrently readsEmailVerifiedto 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)
- What it is: the command that kicks off the email-confirmation send, wrapping a
SendEmailConfirmationRequest(SendEmailConfirmationCommand.cs:20-21). - Depends on:
ICommandWithRequest<TRequest>,IInternalCommand,SendEmailConfirmationRequest; externals: none. - Concept reinforced, the dual-marker command. Implementing both
ICommandWithRequest<TRequest>andIInternalCommandin one declaration lets the same command type be dispatched from a public controller action (carrying a wire request) and re-dispatched internally (e.g. after registration) without a second type. - Walkthrough: a
sealed recordwrapping oneSendEmailConfirmationRequest Requestproperty. - Where it's used: raised by
EmailConfirmationControllerandAuthController; handled bySendEmailConfirmationHandler.
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,UserDataExportEngagementSectionDTOand 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 constantSectionName(EngagementUserDataExportSection.cs:23), oneExportAsync, 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) andSubmittedQuestions(:41-46) project scalar ids plusCreatedOn. No session or question titles are pulled across: Engagement stores ids, and the export is truthful about what Engagement actually holds.PointsEntries(:47-55) convertsActivityTypewith.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 carriesPointsandSubjectKey(:52-53), the scalar ledger facts.CheckIns(:56-65) applies the same rule toScope(:58-60) and carries the nullableEventId,SessionId, andSponsorIdalongsideCheckedInOn, which is what makes the three check-in scopes distinguishable in the output.PollVotes(:66-71) projectsLivePollId,OptionId, andCreatedOn, andQuestionUpvotes(:72-76) projectsQuestionIdandCreatedOn. 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.IsOnLeaderboardandLeaderboardDisplayName(:77-78) close the section. Those are the same two facts Engagement'sUserDeletedPointsHandlererases 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 theAddMmcaApplicationPipelinechain (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 byMMCA.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 asextension(IServiceCollection services)(TokenPermissionGrants.cs:36) exposingAddTokenPermissionGrants(). - Walkthrough:
AddTokenPermissionGrants()(TokenPermissionGrants.cs:44-45) callsservices.AddPermissions(ApplyAll).ApplyAll(PermissionRegistryBuilder permissions)(TokenPermissionGrants.cs:53-59) calls each module's ownApply(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 entirelyIncludecalls: the four reusable rule sets fromMMCA.Common.Application.Validationcarry 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, andUserInvariants.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 policyChangePasswordRequestValidatorapplies 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).Whenis 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>byScanModuleApplicationServices<ClassReference>()(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:58) and injected intoAuthenticationValidators, whichAuthenticationServicereceives through its base and runs at the top of the registration workflow. The endpoint isPOST auth/registeronAuthController.
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 viaUserOwnershipRule.CheckOwnership, returning aUser.ExportForbiddenerror (ExportUserDataHandlerBase.cs:80-90), a no-tracking load throughGetReadRepositorywithError.NotFoundwhen the account is gone (:92-98), the subject snapshot, the section fan-out (:104-108), and the envelope stamped withCurrentFormatVersion"1.0" and aGeneratedOnfromTimeProvider(: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 itsDbContext, 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 toAvailable = falsewith a deliberately generic reason, sending the exception detail to the log and never to the subject (:185-197);OperationCanceledExceptionis 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 insideUserRole.IsOrganizerrather 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 aUserDataExportSubjectDTO: identity and profile fields,IsExternalLoginandLoginProvider,LinkedSpeakerId,AvatarUrl, the seven MAUI device fields, and the two audit timestamps (:48-73). What is absent is the point: noPasswordHash, noPasswordSalt, no refresh token, no externalProviderKey. 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 asKind=Unspecified, and the DTO documents them as UTC but serializes them without aZmarker in that state, soDateTime.SpecifyKind(..., DateTimeKind.Utc)only restores the marker on a value that was already UTC.LastModifiedOnis 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'sIQueryHandler<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
UsersDataExportControllerasIQueryHandler<ExportUserDataQuery, Result<UserDataExportDTO>>(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersDataExportController.cs:27) and invoked from the inheritedGET Users/{userId}/exportaction, which the framework'sDataExportControllerBase<TQuery>declares[Authorize]and feature-gates onPrivacyFeatures.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 byMMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserDataHandlerTests.csand, 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 aUserby 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 asChangePasswordHandleroverChangePasswordHandlerBase<TUser, TCommand>. - Walkthrough: the primary constructor forwards
unitOfWork,tokenService,emailSender,settings,loggerstraight to the base (SendEmailConfirmationHandler.cs:25-26).FindUntrackedByEmailAsync(:29-33) queriesUnitOfWork.GetReadRepository<User, UserIdentifierType>()withasTracking: falsefor the firstUserwhoseEmailmatches, returningnullwhen 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 byEmailConfirmationControllerandAuthController.
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 aResult. - 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.ExecuteAsyncon the outside,ProblemDetailsResultReader.ReadAsyncon the inside. The reader converts a response into aResult, parsing the API'sProblemDetailsand preserving the originalErrorTypewhere 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, anHttpClienttimeout (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 aResult. The one exception is deliberate and stated: when the caller's own token is why the operation stopped, theOperationCanceledExceptionis 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 suppliesCreateAuthenticatedClientAsync()andRetryPolicy, 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 withUri.EscapeDataString(UserService.cs:38-52). That filter is whySelfHttpWarmupTaskwarms exactlyusers?pageNumber=1&pageSize=10: with no filters set, only the paging pair survives. The body is read back asPagedCollectionResult<UserListDTO>(:64-65) and then flattened withresult.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) buildsusers/{id}withCultureInfo.InvariantCulture(an id must never be culture-formatted), retries, and returns the reader's bareResult.GetMyAvatarUrlAsync(UserService.cs:96-117) is the one method with a status-code special case: a 404 short-circuits toResult.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 toAvatarUrl ?? string.Empty(:112-115).UploadMyAvatarAsync(UserService.cs:120-144) posts aMultipartFormDataContentwith onefilepart 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'sResult.ExportAsync(UserService.cs:161-190) buildsusers/{id}/exportwithCultureInfo.InvariantCultureand 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 throughProblemDetailsResultReader.ReadAsyncand is re-wrapped withResult.Failure<...>(failure.Errors)(:177-181). On success it reads the bytes and takes the file name from theContent-Dispositionheader, preferringFileNameStaroverFileNameand 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
Resultrather than throwing is what lets the pages branch:ProfileusesTryGetValueon the avatar read (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/Profile/Profile.razor.cs:58),IsSuccesson the avatar removal (Profile.razor.cs:176), andIsFailureon the account delete (Profile.razor.cs:211); the removal and the delete sit in atrythat catches only theOperationCanceledExceptionof 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
IUserUIServiceinAddIdentityUI(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/DependencyInjection.cs:39) and consumed byUserList(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Users/UserList.razor.cs:75,89,98) andProfile(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)
- What it is: a one-field internal command carrying the blob name to delete from storage:
DeleteAvatarBlobInternalCommand(string BlobName)(DeleteAvatarBlobInternalCommand.cs:17). - Depends on:
IDeleteBlobInternalCommand; externals: none. - Concept reinforced, the fire-and-forget internal command.
[Rubric §6, CQRS & Event-Driven]: marking thisIDeleteBlobInternalCommand(itself anIInternalCommand) rather than a publicICommandsignals it is dispatched from inside the module's own handlers, not from an API controller.DeleteAvatarBlobInternalCommandHandleris the sole handler, now the module's sealed subclass of the sharedDeleteBlobInternalCommandHandlerBase<TCommand>. - Walkthrough: a
sealed recordwith one positional parameter,BlobName. No validation on the type itself; that lives inDeleteAvatarBlobInternalCommandValidator. - Why it's built this way: three call sites (
DeleteUserHandler,RemoveUserAvatarHandler,SetUserAvatarHandler) each need to delete a blob after a domain change succeeds; carrying only the blob name keeps the command decoupled from which use case triggered it. ImplementingIDeleteBlobInternalCommandrather thanIInternalCommanddirectly is what letsDeleteBlobInternalCommandHandlerBase<TCommand>constrain its generic parameter and readcommand.BlobNamewithout a per-command adapter. - Where it's used: dispatched by
DeleteUserHandler,RemoveUserAvatarHandler, andSetUserAvatarHandler; handled byDeleteAvatarBlobInternalCommandHandler.
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 onlyHttpContext.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'ssub, which the bearer handler surfaces asnameidentifier. - 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). ReturningActionResultwith 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 twoProducesResponseTypeentries (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
Profilerenders 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,LegalSettingsviaIOptions(:54),ViewerTimeZone(:62),IExternalLinkServiceandIShareService(:55-56),IToastService, andIJSRuntimefor 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 throughUserService.ExportAsync(UserId, ...)(:172), then branches onExternalLinks.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 throughShare.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 aDotNetStreamReferenceand calls a collocated JS module'ssaveFile(: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 withPath.GetFileName, so it cannot steer the native staging write out of the temp directory, and falls back touser-data-{userId}.jsonunderInvariantCulturewhen the name is empty,.or... - Concept reinforced, the component-scoped lifetime token.
[Rubric §18, UI Architecture]. Same shape asProfile: a privateCancellationTokenSource(:43), aLifetimeTokenproperty over_cts.LifetimeToken()(:66) so a call that resumes after disposal gets a cancelled token, andDisposethat cancels then disposes (:89-111).OperationCanceledExceptionis swallowed in the load and in the download (:133-136,:212-215). The genericcatch (Exception)(:216-220) is deliberate: this runs from anOnClickcallback, 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 aSnackbar.DownloadDataFailedtoast. - Walkthrough
- Status lines (
:74-86):TermsStatusTextisnullwhen the standing is unknown or the host configures no terms version, the "accepted on {date}" line when bothAcceptedVersionandAcceptedOnare present (date formatted throughViewerTime.Format(acceptedOn, "d")), and "not accepted" otherwise.TermsOutdatedTextappears only when an older version is on record andIsCurrentis false. OnInitializedAsync(:114-137): resolvesILegalAcceptanceUIServicewithGetServiceand 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.
- Status lines (
- 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
UserIdparameter (:51,[EditorRequired]) and reads the standing from the token-scoped framework client. The server side of the standing isLegalAcceptanceController; the export is served byUsersDataExportController. - Where it's used: rendered by
Profile;LegalAndDataCard.razor.csis 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 sharedDeleteBlobInternalCommandHandlerBase<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 inDeleteBlobInternalCommandHandlerBase<TCommand>.HandleAsync, which treatsErrorType.NotFoundfromfileStorage.DeleteAsyncas 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
fileStorageandloggerto 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:
DeleteSessionAssetBlobInternalCommandHandlerin the Conference module needed the identical delete-and-tolerate-not-found flow, so the shared logic (including the three[LoggerMessage]methods) moved toMMCA.Common.Application.InternalCommands.DeleteBlobInternalCommandHandlerBase<TCommand>; each module now contributes only itsBlobKindlabel instead of duplicating the handler. - Where it's used: registered as the handler for
DeleteAvatarBlobInternalCommandthrough 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)
- What it is: the FluentValidation validator for
DeleteAvatarBlobInternalCommand: requiresBlobNameto be non-empty (DeleteAvatarBlobInternalCommandValidator.cs:10-15). - Depends on:
FluentValidation.AbstractValidator<T>(external),DeleteAvatarBlobInternalCommand. - Concept reinforced, the one-rule request validator. A minimal sibling of the validators taught in depth elsewhere in this group (
RegisterRequestValidator,SetUserAvatarCommandValidator): oneRuleForin the constructor,NotEmpty(). - Where it's used: wired into the CQRS pipeline's decorator chain for
DeleteAvatarBlobInternalCommand; its presence is asserted by the architecture fitness testCommandValidatorCoverageTests, which checks every command has a validator.
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 sharedChangePasswordCard), reach the signed-in-devices page, see the legal documents and download their data (theLegalAndDataCardsub-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,IMediaPickerServiceandPickedMedia,ChangePasswordCardandLegalAndDataCard(markup,Profile.razor:85,:109), plus Blazor'sAuthenticationStateProvider,NavigationManager,InputFileChangeEventArgs, and MudBlazor'sMudMessageBox(Profile.razor.cs:1-25). The localizerLis 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 onMediaPicker.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 whyIUserUIService's upload method is typed on a rawStreamrather 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 holdsprivate 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.Tokendirectly: every service call passes theLifetimeTokenproperty (Profile.razor.cs:35,_cts.LifetimeToken()), because readingTokenoff a disposed source throwsObjectDisposedException; sinceDisposecancels before it disposes, a load or handler that resumes after the user navigated away gets an already-cancelled token and stops through itsOperationCanceledExceptionpath (comment at:32-34). Every async entry point then catchesOperationCanceledExceptionand 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 intoIUserUIServicereturns aResult, and the page reads it withTryGetValueorIsSuccessrather 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): awaitsAuthStateProvider.GetAuthenticationStateAsync()and reads the subject with the sharedGetUserId()claims helper (:52), whose comment records that it accepts both thesuband thenameidentifierform 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._isLoadingis cleared infinally(: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 inUsersController(UsersController.cs:92-98) and again inSetUserAvatarHandler. - Avatar paths:
PickAvatarAsync(:82) andCaptureAvatarAsync(:86-87) both funnel throughUploadPickedMediaAsync(pick, noMediaMessage)(:89-119), which disposes thePickedMediawithusing(:94). When the picker returnsnull(media is null,:95-103) the behavior now depends on the caller: picking passesnoMediaMessage: nulland returns silently, but capturing passesL["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 pathOnBrowserInputChangedAsync(:121-156) opens the stream withfile.OpenReadStream(MaxAvatarBytes, LifetimeToken)(:138) and wraps it inawait usingso the read stream is released even on failure.UploadAvatarStreamAsync(:158-169) toasts on a failed result and only assigns_avatarUrlon success (:167-168). RemoveAvatarAsync(:171-194): the failure branch of the result toastsAvatar.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, orSavePasswordAsync. The page keeps only the card frame and heading and drops in the sharedChangePasswordCardfromMMCA.Common.UI, which owns the fields, the required, min-length and match validators, theErrorSummaryand 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 inChangePasswordRequestValidatorandChangePasswordHandler. - Signed-in devices (
Profile.razor:88-104): a card whose only content is a link toRoutePaths.Sessions(:100). The comment above it records why there is no logic here (:88-89): the sessions page itself ships fromMMCA.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). SeeLegalAndDataCard. DeleteAccountAsync(:196-229): guards on a known_userId(:198-202), shows theMudMessageBox(Profile.razor:129-139) and aborts unless the answer is exactlytrue(confirmed is not true,:204-206, so cancel and dismiss both abort), then deletes throughLifetimeToken(:211), callsAuthService.LogoutAsync()(:217), toasts, and navigates to/withforceLoad: 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 explicitaria-labelon theMudAvatar(Profile.razor:33) because MudBlazor rendersrole="img", which the axerole-img-altrule 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
/profilefor any authenticated user in both the web and MAUI heads. Its server round trips land on the Identity avatar endpoints onUsersController,AuthController'sPUT password, andUsersController'sDELETE {userId}. - Caveats / not-in-source: the client-side password rules (minimum length, match) now live inside the shared
ChangePasswordCardinMMCA.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 sharedUserAdminList<TUser>(ADR-116), which renders the roster, the mobile cards, and the administration actions (lock, unlock, change role) against the framework'sAdmin/Usersendpoints. 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 throughIUserUIServicerather than the framework's administration read service, because ADC's/Usersendpoint 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 ownDataGridListPageBase<UserListDTO>subclass with its own grid, mobile list, search, and delete wiring; that machinery moved intoUserAdminList<TUser>and this class shrank toComponentBaseplus a viewer-clock hook and one data-fetch method. - Walkthrough
AssignableRoles(UserList.razor.cs:31-32): the same least-to-most-privileged role arrayUserDetailcarries, kept here because the base's inline role editor needs it for this list.ViewerTimeandOnAfterRenderAsync(:36-50): the Created On column renders on the viewer's clock, so the page injectsViewerTimeZone(:37) and, on first render only, awaitsViewerTime.EnsureResolvedAsync()once JS is available and callsStateHasChanged()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 filtersIUserUIService.GetPagedAsynctakes (BR-51). The free-text search box arrives underUserAdminList<UserListDTO>.SearchFilterKeyand wins over an explicitEmailcolumn 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 theUsersReadcapability inIdentityPermissions(UsersController.cs:139); the lock/unlock/role actions it delegates to the base callUsersAdminController.RoleListreusesUserList.razor.csas 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) andPOST confirm-email(redeems it). It is now a body-light subclass: both actions are inherited fromEmailConfirmationControllerBase<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 overSendEmailConfirmationCommandandConfirmEmailCommand; 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:SendEmailConfirmationAsyncalways answers202 Acceptedon 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 posturePasswordResetControllertakes onforgot-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 throughICommandWithRequest<TRequest>(:46-47). - Walkthrough
- The primary constructor (
EmailConfirmationController.cs:29-33) takes the two closed command handlers and forwards them to the base. RouteAuthand version1.0stay on this class, repeated because the base leaves them to the derived controller (base remarks,EmailConfirmationControllerBase.cs:38). CreateSendCommand(:37) andCreateConfirmCommand(:40): one-line=> new(request)factories buildingSendEmailConfirmationCommandandConfirmEmailCommand, 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 aRefererheader.
- The primary constructor (
- 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
PasswordResetControlleruses. Both inherited actions end in the sameresult.IsFailure ? HandleFailure(result.Errors) : ...tail every controller in the module uses, so a validation or not-found failure becomes the sharedProblemDetailsshape. - Where it's used: registration schedules a
SendEmailConfirmationCommandfromAuthControllerafter a successfulPOST register(ADR-116); the confirmation page then callsconfirm-emailwith 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/Usersendpoint surface (list, get, lock, unlock, set role). It declares no new behavior beyond two attribute-only overrides: every action is inherited fromUsersAdminControllerBase<TUserDto>. - Depends on:
UsersAdminControllerBase<TUserDto>(base, closed overUserAdminDTO),IUserAdministrationService<TUserDto>,NonIdempotentAttribute. - Concept reinforced, the sibling controller pattern applied to administration.
[Rubric §15, Best Practices & Code Quality]. LikeOAuthControllerandUsersDataExportController, the class exists to bind a shared base to an app-specific service, hereUserAdministrationServicethroughIUserAdministrationService<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) andUnlockAsync(:32-33), each a one-linebase.___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 byUserDetailandUserListthrough the framework'sIUserAdminUIService<TUserDto>client.AdminRolesControlleris 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 atOAuthController.cs:6to 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 ownAuthenticationService, which is what makes the base's provider-agnostic flow create or link an ADCUser.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).ExchangeAsyncthen reads and burns the single-use code insideTryAcquireAsynconlock:{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.APIand each app contributes only its route prefix and its authentication service. Pairing this controller withAddExternalAuthProvidersin 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_verifiedclaim mapping live in the Identity service host'sProgram.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
/usersREST 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>andIQueryHandler<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 fromICurrentUserService, build the command or query record,await handler.HandleAsync(...), thenresult.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"atMMCA.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) soDeleteUserHandlercan 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).
- Declarative, for a capability-shaped rule: the list endpoint carries
- Walkthrough: class-level
[Authorize](UsersController.cs:29) makes every action authenticated by default, and eachme/...action re-checkscurrentUserService.UserId is nulland returnsUnauthorized()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 friendlyAvatar.InvalidUploadcheck 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'sIdempotency-Keyfilter (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-sizedMemoryStream(:100-107), with bothawait usingscopes carryingConfigureAwait(false), and the byte array is handed toSetUserAvatarCommand(: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 apageNumber=0is a model-binding 400 rather than a handler concern.DELETE {userId}(:162-181), the ownership-checked action, declaring 403 and 404 in itsProducesResponseTypeset (:164-165).
- Two size constants, and the reason there are two.
- 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 aProblemDetailswith the same shape, mapped once inApiControllerBase. The GDPR export, which used to be a sixth action here, now lives onUsersDataExportControllerand still serves the same/Users/{userId}/exportpath. - Where it's used: mounted by the Identity service host and routed through the Gateway; consumed by
UserServiceon the client side (and therefore byUserListandProfile), and warmed at startup bySelfHttpWarmupTask, which replaysusers?pageNumber=1&pageSize=10against 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 fromRolesAdminControllerBase, 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 shapeOAuthControllerandUsersDataExportControlleruse 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
Userrows. - Where it's used: mounted by the Identity service host's
Program.csand routed through the Gateway;UsersAdminControlleris 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,
GETandPOST /Users/me/legal-acceptance. LikeOAuthControllerit is body-less: the actions, their authorization and the version rules all come fromLegalAcceptanceControllerBase, 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,LegalAcceptanceOptionsviaIOptions, and theLegalAcceptanceDTOit 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,GETanswersIsCurrent = truewithout 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 literalUsers, not[controller], because the base fixes the action template tome/legal-acceptanceand the published path must stay/Users/me/legal-acceptance, which the Gateway already forwards under its/Users/**route (:16-19). Same trick asUsersDataExportControllerandPasswordResetController.- The three constructor parameters (
:25-27) are passed straight to the base. The acceptance store itself is ADC'sLegalAcceptanceService.
- 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, whichLegalAndDataCardreads to render the accepted-terms line; backed byLegalAcceptanceService.
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. LikeOAuthControllerit declares no action of its own: the action, its authorization, its feature gate, and the file-download response all come fromDataExportControllerBase<TQuery>, and this class supplies the route and one query factory. - Depends on:
DataExportControllerBase<TQuery>(base),IQueryHandler<in TQuery, TResult>closed overExportUserDataQuery,UserDataExportDTO,ICurrentUserService,Result, andPrivacyFeatures. - 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'sExportUserDataQuerylives 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 abstractCreateQuery(: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 inExportUserDataHandler(.../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 literalUsers, 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 atUserskeeps serving the published/Users/{userId}/exportpath even though the class is namedUsersDataExportController. This is the same trickPasswordResetControlleruses on/Auth.- The primary constructor (
:26-29) takes the closed export handler andICurrentUserServiceand forwards both to the base. CreateQuery(userId, currentUserId, currentUserRole)(:32-35): builds ADC'sExportUserDataQueryfrom the route id and the authenticated caller. The base's constraintwhere 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 withPrivacy.Unauthorized(:86-90), build the query and dispatch (:92-94), then serialize the package withJsonSerializer.SerializeToUtf8Bytes(export, JsonSerializerOptions.Web)and returnFile(...)(: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 ownGeneratedOnunderInvariantCulture(: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
AuthControllerBaseprecedent 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/Usersroute. - Caveats / not-in-source: whether the endpoint is reachable in a given environment depends on the
PrivacyFeatures.DataExportflag 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-passwordandPOST /Auth/reset-password. LikeOAuthControllerit 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, atMMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43),ICommandHandler<in TCommand, TResult>closed overForgotPasswordCommandandResetPasswordCommand,Result, and the sharedForgotPasswordRequest/ResetPasswordRequestcontracts; 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.AuthControlleralready 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/Authprefix 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 withUserClaimsController'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 answers202 Accepted(:79,:92) whether or not the address holds an account, so a caller cannot enumerate registered addresses, and every reset rejection collapses into one401(: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 asForgotPasswordHandlerandResetPasswordHandler(PasswordResetAuthControllerBase.cs:50,:53). CreateForgotPasswordCommand(ForgotPasswordRequest request) => new(request)(PasswordResetController.cs:36) andCreateResetPasswordCommand(ResetPasswordRequest request) => new(request)(:39), the two abstract factories the base declares (PasswordResetAuthControllerBase.cs:61,:69). This is the same splitAuthControlleruses for change-password: the workflow is shared, the command records are not, because ADC marks its reset commandICacheInvalidatingand Store does not (ResetPasswordCommand.cs:16-19).- The inherited actions themselves:
ForgotPasswordAsync(PasswordResetAuthControllerBase.cs:82-93) dispatches the app command and returnsAccepted();ResetPasswordAsync(:107-118) dispatches and returnsNoContent(). Both end in the sameresult.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
ForgotPasswordHandlerandResetPasswordHandler; driven by unauthenticated browser and MAUI clients through the Gateway's existing/Authroute. - 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
/authendpoint 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 extendsAuthControllerBase),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 plusPUT password(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:87),PUT preferences(:112), andGET preferences(:138). It cannot own the command records, because ADC marks its change-password commandICacheInvalidatingwith a cache prefix built from ADC's ownUsertype 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.PasswordResetControlleris 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].RegisterAsyncno longer stops at201 Created: once the registration itself succeeds, it schedules aSendEmailConfirmationCommandthroughIInternalCommandScheduler(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 noUserRegistereddomain event (only the cross-service integration eventUserRegistered), andAuthenticationServicealready 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 the201: 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 throughLogConfirmationScheduleFailed(:98-101,:106-107) because a recurring one means nobody is being asked to confirm anything. The synthetic-account skip (:74-79): whenSyntheticAccounts.IsSynthetic(request.Email)is true,RegisterAsyncreturns the same201 Createdimmediately 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:IInternalCommandSchedulerandILogger<AuthController>), then four members.RegisterAsync(AuthController.cs:53-104) is a genuine override rather than a pass-through. It passes the base'sClientIpAddressandClientUserAgentintoAuthenticationService.RegisterAsync(:64-66; the two properties are computed fromHttpContexton the base). Success returns201 Createdexplicitly (:103, or:78on the synthetic-account path), after the confirmation-scheduling step above.LoginAsync(AuthController.cs:120-123) overrides only to re-declare attributes, then callsbase.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.CreateChangePasswordCommandandCreateChangePreferencesCommand(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 nowpartial(:33) because the[LoggerMessage]source generator needs a partial method declaration forLogConfirmationScheduleFailed(:106-107). Both anonymous actions are marked[AllowAnonymous]with the fully qualified attribute name because the file'susingset 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
Profilepage uses the inheritedPUT auth/passwordand the preferences pair;ChangePasswordRequestValidatorguards the password action through the Validating decorator; a successful register schedules the confirmation link handled byEmailConfirmationController. Its anonymous sibling on the same/Authprefix isPasswordResetController.
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 overUserAdminDTO),IAppDialogService,IToastService,RoleNames; externals: Blazor'sAuthenticationStateProvider,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 duringOnInitializedAsync(: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_currentUserIdfromAuthStateProvider.GetAuthenticationStateAsync().OnParametersSetAsync(:81): callsLoadAsyncon 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 patternUserList's sibling pages rely on.LoadAsync(:83-92): fetches the account throughUserAdminService.GetAsync(Id, LifetimeToken)and seeds_selectedRolefrom the loaded user, falling back toRoleNames.Attendeewhen the fetch failed.ToggleLockAsync(:94-134): confirms throughDialogs.ConfirmAsyncwith a lock- or unlock-worded message built from the account's own email, then callsUserAdminService.LockAsyncorUnlockAsync, toasts the outcome, and reloads on success._isBusyis set before the call and cleared infinally(:114,:130-133); the lock, unlock and role calls all passLifetimeToken.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 asToggleLockAsync, throughUserAdminService.SetRoleAsync.Dispose(:174-179): cancels and disposes the page'sCancellationTokenSourceand suppresses finalization.
- Why it's built this way: every service call takes
LifetimeToken(:36,_cts.LifetimeToken()) rather than_cts.Token, because readingTokenoff a disposed source throwsObjectDisposedException;Disposecancels before it disposes, so a load or handler that resumes after the user navigated away gets an already-cancelled token and stops through itsOperationCanceledExceptionpath.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 disciplineUserList's delete uses. - Where it's used: routed from
UserListas the per-account detail page in the ADC web and MAUI UI; the account it edits is read and written throughIUserAdminUIService<UserAdminDTO>, the framework's shared administration client (ADR-116), which calls the API surfaceUsersAdminControllerexposes.
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
SpeakerLinkedToUserintegration event. It setsUser.LinkedSpeakerId, the Identity half of the bidirectional User-to-Speaker link (BR-209). - Depends on:
ScopedIntegrationEventHandlerBase<TIntegrationEvent>(base, which implementsIIntegrationEventHandler<in TIntegrationEvent>);SpeakerLinkedToUserfromMMCA.ADC.Conference.Shared;IUnitOfWorkandIRepository<TEntity, TIdentifierType>;User;IServiceScopeFactoryandILoggerwith 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 asIUnitOfWork. Every handler therefore needs a DI scope per delivery. Rather than repeat that preamble, this class extendsScopedIntegrationEventHandlerBase<SpeakerLinkedToUser>(SpeakerLinkedToUserHandler.cs:32-35), which opens the async scope, hands the subclass that scope'sIServiceProvider, 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'sSharedassembly, 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 placeUser.LinkedSpeakerIdchanges in response to a Conference-side change. - Walkthrough (primary constructor
:27-30,HandleScopedAsync:33-58)- Scope-resolved services (
:38-39),IUnitOfWorkfrom the providedIServiceProvider, thenGetRepository<User, UserIdentifierType>()(the mutating repository, because this path writes). The null guard on the event itself lives in the base (ScopedIntegrationEventHandlerBase.cs:47). - 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. - Idempotency check (
:48-52), whenLinkedSpeakerIdalready equals the event'sSpeakerId, 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. - Apply and persist (
:54-55),user.LinkSpeaker(...)thenSaveChangesAsync, followed by an information-level log (:57, template at:84). - 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 thethrow;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.
- Scope-resolved services (
- 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 nullLinkedSpeakerIdwhile 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'sSpeakerUnlinkedFromUserand clearsUser.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 atSpeakerLinkedToUserHandler; 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 whenLinkedSpeakerIdhas no value, which is the idempotent-redelivery case (:48-52); the stale-event guard above (:54-61); thenuser.UnlinkSpeaker()plusSaveChangesAsyncand 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 serviceUsersAdminControllercalls: 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]. BothSetLockedAsync(:627-659) andSetRolesAsync(:662-712) callRevokeSessionsAsync(: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-701for the role case). The comment at:705-706states 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 throughPagingMath.Clamp(:576), narrows withApplyFilters, counts, then orders.OrderBy(u => u.Email).ThenBy(u => u.Id)beforeSkip/Take(:588-590). TheIdtie-break is called out in the surrounding comment (:586-587): without it, rows sharing an email sort key can repeat or vanish across pages, becauseOFFSET/FETCHhas no stable row order to page over otherwise. The projection toUserAdminDTOhappens in the query (:593-602).ApplyFilters(:739-760): the search term matches the whole email address by parsing it throughEmail.Createfirst (:744-748), becauseUser.Emailis a converter-backed value object the provider can only compare against another convertedEmail; an unparseable term simply matches no address, the same answer the database gives for an address nobody holds. The two name predicates useContainsand stay independent of whether the email half parsed.SetRolesAsync(:662-712): rejects anything but exactly one role with aUser.Role.ExactlyOnevalidation error (:667-673, ADC accounts hold exactly one role), parses it throughUserRole.FromString, then defers toUser.ChangeRolefor the domain rule.GetAsync/ToDto(:611-624,:714-723): a not-foundResultwhen the id misses, otherwise the same DTO shapeListAsyncprojects, 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 withRefreshSession.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'sDependencyInjectionand resolved byUsersAdminController; its DTO shape is the oneUserDetailandUserListrender.
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
Useraggregate toUserDTO, for the single and the collection case. - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>(implemented),User,UserDTO, theEmailvalue 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 apartialclass (UserDTOMapper.cs:13-15) tells Mapperly to generate the body of everypartialmethod 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.Emailis anEmailwhileUserDTO.Emailis astring, and Mapperly resolves that by finding a method in the class whose signature converts one to the other: the privatestatic string EmailToString(Email email) => email.Value(:28). Adding that method is the entire configuration. - Walkthrough
MapToDTO(User entity)(:18): declaredpartialwith no body; the generator emits the property-by-property assignment.MapToDTOs(IReadOnlyCollection<User>)(:21-25): hand-written, not generated. It null-guards withArgumentNullException.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 fromUserDTOsimply not declaring those members (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDTO.cs:8-24declares exactlyId,Email,FirstName,LastName,Role, allrequiredandinit-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:GetUsersHandlerprojects straight toUserListDTOin the database, so the sensitive columns are never read at all. - Caveats / not-in-source: the doc comment still names
RefreshTokenamong the excluded fields (:11), a propertyUserno longer has; sessions moved to the framework'sRefreshSessionstable (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
UserIdwith theChangePasswordRequestbody, opts into automatic request validation, and evicts the user cache on success. - Depends on:
ChangePasswordRequest; theUserIdentifierTypealias;User(only fortypeof(User).FullName); and three framework markers,ICommandWithRequest<out TRequest>,ICacheInvalidating, andIUserScopedCommand<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 beyondCachePrefix; 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 aCommandRequestValidator<TCommand, TRequest>that delegates to the registeredIValidator<ChangePasswordRequest>through FluentValidation'sSetValidator, withTryAddsemantics so an explicit command-level validator still wins (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/ICommandWithRequest.cs:5-11).ICacheInvalidatinggives 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 moveUserand the prefix follows.ResetPasswordCommandcarries 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 computedCachePrefixproperty (:17-18).UserIdcomes from the authenticated principal,Requestfrom 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
TCommandprecisely so each application can keep its own marker set on the record while sharing one workflow. - Where it's used: constructed by
AuthController'sCreateChangePasswordCommandoverride (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:126-128) and handled byChangePasswordHandler.
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
UserIdwith the partialChangePreferencesRequestand evicts the user cache so a preference change cannot be masked by a stale cached read. - Depends on:
ChangePreferencesRequest; theUserIdentifierTypealias (= intin this module);User(only fortypeof(User).FullName);ICacheInvalidatingandIUserScopedCommand<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 onlyICacheInvalidatingandIUserScopedCommand<ChangePreferencesRequest>(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:15), with noICommandWithRequest<out TRequest>, so the framework's reflection loop never registers aCommandRequestValidator<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-writesChangePreferencesCommandValidatordirectly over this command type, whichAddValidatorsFromAssembly(moduleAssembly)picks up (DependencyInjection.ModuleScanning.cs:117) and the validating decorator runs. The aggregate still owns the last word regardless, becauseUser.UpdatePreferencesre-checks both values throughUserInvariants(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 moveUserand 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
ICacheInvalidatingwith a prefix built from its ownUsertype 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'sCreateChangePreferencesCommandoverride (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 byChangePreferencesHandler.
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
UserIdentifierTypealias;User(fortypeof(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.IUserOwnedRequestextendsIUserScopedRequestwithCurrentUserIdand a nullableCurrentUserRole(MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserOwnedRequest.cs:8-15), which is exactly the triple the sharedUserOwnershipRuleneeds 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 implementsITransactional(DeleteUserCommand.cs:22), and its<remarks>(:8-15) explains why: the soft-delete flag, the anonymized personal data, theUserDeletedoutbox 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; withoutITransactionalthe schedule could flush ahead of a half-finished erasure. - Walkthrough: a three-parameter positional record,
UserId,CurrentUserId,CurrentUserRole(DeleteUserCommand.cs:11-14), plus the computedCachePrefix => $"{typeof(User).FullName}:"(:17).CurrentUserRoleisstring?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
HttpContextinside 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'sDeleteAsyncfromcurrentUserService.UserIdandcurrentUserService.Role(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:166-181); handled byDeleteUserHandler.
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(fromMMCA.Common.Shared.Auth);ICommandWithRequest<out TRequest>andICacheInvalidating;User, fortypeof(User).FullNameonly. - Concept reinforced, the same two markers with the opposite validation route. Put this record next to
ChangePreferencesCommandabove and the marker system explains itself. Both are one-line records with the sameCachePrefix; the difference is that this one does implementICommandWithRequest<ResetPasswordRequest>(ResetPasswordCommand.cs:16), which opts it into automaticCommandRequestValidator<TCommand, TRequest>registration, so the validating decorator runsResetPasswordRequestValidatoragainst the embedded payload before the handler ever sees the command.[Rubric §11, Security]: that validator includesStrongPasswordRules<T>overNewPassword(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 noUserIdparameter. 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), plusCachePrefix => $"{typeof(User).FullName}:"(:18). The payload itself is areadonly record structof three strings,Email,Token,NewPassword(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/ResetPasswordRequest.cs:9-12), whose doc comment marksNewPasswordas 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 throughICommandWithRequest<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'sCreateResetPasswordCommandoverride, a single expression-bodiednew(request)(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:39), behindPOST /Auth/reset-password; handled byResetPasswordHandler.
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, andChangePasswordCommand. - 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 intoMMCA.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 asource, the base defaults it toGetType().Namethrough a virtualHandlerName(ChangePasswordHandlerBase.cs:57-62), and clients match on the stringChangePasswordHandler. 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 atChangePreferencesHandler,GetUserPreferencesHandler, and (for the controller layer) atPasswordResetControllerandUsersDataExportController. - Concept introduced, revoking live sessions on a credential change (SEC-Common-02).
[Rubric §11, Security]. The constructor takesIRefreshSessionStoreand aTimeProvider, 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.ResetPasswordHandlercarries the identical addition for the same reason. - Concept introduced, throttling the current-password check (ADR-029).
[Rubric §11, Security]. The constructor also takesILoginProtectionService, 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 keyedpassword-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,ILoginProtectionServiceandTimeProvider, 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 returnError.NotFoundtagged withHandlerNamewhen absent (:71-76); reject an account with no stored hash or salt (an external-OAuth account) with the sameError.Unauthorized("Auth.InvalidCurrentPassword", ...)(:82-86); return the lockout failure if thepassword-changekey is locked (:88-93); verify the supplied current password withpasswordHasher.VerifyPassword(command.Request.CurrentPassword, user.PasswordHash, user.PasswordSalt)and, on a mismatch, increment the failed-attempt counter and returnError.Unauthorized("Auth.InvalidCurrentPassword", ...)(:95-100); reset the counter on a match (:102); hash the new password into a fresh hash and salt pair (:104); calluser.ChangePassword(newHash, newSalt)and, only when that succeeds, save, revoke every refresh session and log (:105-117); return the aggregate'sResultunchanged (: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 constraintwhere TUser : AuditableAggregateRootEntity<UserIdentifierType>, IPasswordChangeableUser(ChangePasswordHandlerBase.cs:49) is what lets the base callChangePasswordon 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 intoAuthController(AuthController.cs:37), which exposes it through the base'sPUT /auth/passwordaction; theProfilepage 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 implementICommandWithRequest<out TRequest>, and the framework registers aCommandRequestValidator<TCommand, TRequest>bridge that forwards to the payload's validator (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:121-135).ChangePreferencesCommanddeliberately does not wear that marker, so this validator is written against the command type itself and is picked up by the plainAddValidatorsFromAssembly(moduleAssembly)scan a few lines earlier (DependencyInjection.ModuleScanning.cs:117). Both routes land in the same place:ValidatingCommandDecorator<TCommand, TResult>resolvesIEnumerable<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 onesUserInvariants.EnsurePreferredCultureIsValidandEnsurePreferredThemeIsValidenforce 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 isUser.UpdatePreferences, which combines both checks withResult.Combineand 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 codeUser.Preferences.Required. Every rule here carries an explicitWithErrorCode, 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). TheWhenclause 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.IsSupportedmatches case-insensitively against the framework allowlistAll, 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 sameWhenshape over the private helperIsKnownTheme(:43-45), anOrdinalIgnoreCasecomparison againstCommonInvariants.LightTheme("light") andCommonInvariants.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 fromSupportedCultures.Allrather 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 intoValidatingCommandDecorator<TCommand, TResult>as one element ofIEnumerable<IValidator<ChangePreferencesCommand>>aroundChangePreferencesHandler. - Caveats / not-in-source: the
NotNullrule onRequestand theWhenguards both testx.Request is not null, even thoughChangePreferencesRequestarrives 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 ofnull.
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, andChangePreferencesCommand. - Concept reinforced, the name-preserving thin subclass (introduced at
ChangePasswordHandler); the<remarks>here carries the same rationale, that the class name is kept so thesourcereported on every error staysChangePreferencesHandler, 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" contractChangePreferencesCommandValidatorencodes with itsWhenclauses one stage earlier. - Walkthrough: a primary constructor taking
IUnitOfWorkandILogger<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>(), returningError.NotFound.WithSource(HandlerName).WithTarget(typeof(TUser).Name)when absent (:46-51); the merge above (:53-55); then, only when the returnedResultis a success,SaveChangesAsyncplus the handler's own source-generatedPreferencesChangedlog (:56-60, declared at:65-66); return the result unchanged (:62). Value checking is not the handler's job:User.UpdatePreferencescombines 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
IsSuccessmeans 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. TheHandlerNameindirection (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 intoAuthController(AuthController.cs:38) asPUT /Auth/preferences; wrapped by the decorator pipeline, whose caching decorator performs the eviction declared onChangePreferencesCommand. - Caveats / not-in-source:
UpdatePreferencesraises 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 itsTryGetBlobNamehelper),DeleteAvatarBlobInternalCommand, andResult. - 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 throughUserOwnershipRule.CheckOwnershipusing the application'sHasDeletePrivilegeanswer (:82-92); load the user,Error.NotFoundwhen 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 scalarUserIdreferences 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 throughIErasableUser erasable = user;rather than callinguser.Delete()directly, because member lookup on a type parameter prefers its class constraint, and ADC'sUserhides the baseDelete()withpublic 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-processUserDeleteddomain 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 aDisplayNamesnapshot on the leaderboard opt-in, Engagement is its own process with its own database, and the in-process domain eventUser.Delete()raises never reaches it. So the override callsuser.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 verySaveChangesAsyncthat 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'sICollection<Func<CancellationToken, Task>> afterCommitparameter (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 callsScheduleAvatarBlobDeletionAsync(:68-70), which enrolls aDeleteAvatarBlobInternalCommandrow throughinternalCommandScheduler.ScheduleAsync(:78-80). Because this hook runs inside the erasure transaction, beforeSaveChangesAsync(DeleteUserHandlerBase.cs:118-139), the scheduled row commits with the anonymized aggregate or rolls back with it (ADR-114), which is exactly whyDeleteUserCommandis now markedITransactional(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,ScheduleAvatarBlobDeletionAsyncreturnsResult.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 whichunitOfWork,cacheServiceandloggerare forwarded straight toDeleteUserHandlerBase<User, DeleteUserCommand>(:35); onlyinternalCommandSchedulerandtimeProviderare 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 anOrdinalIgnoreCasecomparison (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), reusingSetUserAvatarHandler.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 viaScheduleAvatarBlobDeletionAsyncinstead of enqueuing a post-commit action (:68-70); with no blob it returnsResult.Success()directly (:73).ScheduleAvatarBlobDeletionAsync(:76-86): awaitsinternalCommandScheduler.ScheduleAsync(new DeleteAvatarBlobInternalCommand(avatarBlobName), ...)(:78-80) and translates a failed schedule intoResult.Failure(scheduled.Errors)(:85), which propagates up throughOnAfterSoftDeleteAsyncand 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 exceptOperationCanceledExceptionand logs through the handler's own source-generatedSoftDeletedMarkerFailed(: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.
- Constructor (
- 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 sharedSoftDeletedUserMiddlewarereject 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 atUser.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 byUsersController'sDeleteAsync(UsersController.cs:166-181), which returns 204 on success; the callers are theProfilepage's self-service deletion and the organizerUserList. TheUserDeletedintegration event it raises is consumed downstream by Engagement'sUserDeletedPointsHandler. The scheduledDeleteAvatarBlobInternalCommandis drained later byDeleteAvatarBlobInternalCommandHandler. - Caveats / not-in-source: two things to watch. First, the avatar delete no longer runs inline during the request:
ScheduleAvatarBlobDeletionAsynconly 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) andUser.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, andResetPasswordCommand. - Concept introduced, revoking live sessions on a completed reset (SEC-Common-02).
[Rubric §11, Security]. The constructor now also takesIRefreshSessionStoreand aTimeProvider, 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.ChangePasswordHandlercarries 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,PasswordResetRejectedandPasswordResetCompleted(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:userIdcomes 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 inheritedHandleAsync(ResetPasswordHandlerBase.cs:65-114): null guard (:54); read the payload through theICommandWithRequest<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)anduser.ChangePassword(newHash, newSalt)(:79-84), which returns the aggregate's ownResultand is propagated untouched on failure;SaveChangesAsync(:86); thenloginProtection.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, throughUser.ChangePasswordatMMCA.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 intoPasswordResetController(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:30), which exposes it asPOST /Auth/reset-passwordwith[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.ChangePasswordraises the in-processUserPasswordChangeddomain 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 atResetPasswordHandlerBase.cs:110runs 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, andUser. Note what is absent: noILogger. 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 atIQueryHandler<in TQuery, TResult>).[Rubric §6, CQRS & Event-Driven]: the base implementsIQueryHandler<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. BothGetUserPreferencesQueryandUserPreferencesResponsewere 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 lighterAuditableBaseEntity<UserIdentifierType>, IUserPreferencesrather than the aggregate-root constraint the write bases need (:23).[Rubric §27, Internationalization]: this is the server end of "remember my language", readingUser.PreferredCulturestraight off the aggregate. - Walkthrough: a primary constructor taking
IUnitOfWorkand forwarding it (GetUserPreferencesHandler.cs:13-14), empty body. The inheritedHandleAsync(GetUserPreferencesHandlerBase.cs:33-45) guards the query for null (:37), resolvesunitOfWork.GetReadRepository<TUser, UserIdentifierType>()(:39), callsGetByIdAsync(query.UserId, ...)(:40), and returns eitherError.NotFoundtagged withHandlerNameand the aggregate's type name orResult.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 callsSaveChangesAsync, 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 intoAuthController(AuthController.cs:36) asGET /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, fromMMCA.ADC.Identity.Shared), the generatedAttendeeQueryService.AttendeeQueryServiceClientandGetAttendeeUserIdsRequesttypes compiled from the.protoin this project, and theUserIdentifierTypealias. - 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 theSharedlayer. Notification depends on the C# interface; in the monolith that interface resolves to Identity's in-processAttendeeQueryService, 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.Contractsand holds both the.protoand the hand-written adapter (ADR-007, ADR-008). Note the accessibility: the adapter isinternal(:14), so consumers cannot name it; the only way to get it is throughDependencyInjectionin 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, passingdeadline: DateTime.UtcNow.Add(CallDeadline)alongside the cancellation token (:26-29), thenreturn [.. response.UserIds](:33). The collection expression materializes protobuf'sRepeatedFieldinto a plainIReadOnlyList, which the comment (:31-32) notes is deliberate so callers do not leak the generated type; becauseUserIdentifierTypeisint, the projection is a no-op cast.
- Primary constructor (
- 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'sAddIdentityAttendeeClientin 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 isAttendeesGrpcService. - Caveats / not-in-source: the interface returns a plain list rather than a
Result, so a transport fault reaches the caller as a rawRpcExceptionafter the Polly pipeline gives up; there is noResult.Failuretranslation 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.AttendeeQueryServiceBaseand answers the RPC by delegating to whateverIAttendeeQueryServiceis 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 generatedAttendeeQueryServiceBase,GetAttendeeUserIdsRequestandGetAttendeeUserIdsResponsefromattendee_query.proto, andGrpc.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 interceptorGrpcResultExceptionInterceptortranslates aResultFailureExceptioninto anRpcExceptioncarrying 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)- Argument guards on both
requestandcontext(:27-28). The generated base is a public wire surface, so both are checked even though the framework supplies them. 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.- Response construction (
:32-34): a freshGetAttendeeUserIdsResponse, thenresponse.UserIds.AddRange(userIds). Protobuf repeated fields are read-only collections that are populated, never assigned.
- Argument guards on both
- Why it's built this way:
[Rubric §11, Security]. The endpoint is mapped withapp.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'sAttendeeNotificationRecipientProviderunder an organizer-authorized controller), which means an inbound bearer always exists forJwtForwardingClientInterceptorto forward. The comment states the standing condition explicitly: a scheduler or broker-consumer path would run with noHttpContext, 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-303names this class as the endpoint it publishes) and mapped atProgram.cs:373; the only client isAttendeeQueryServiceGrpcAdapter. 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
.Contractsproject exposes.AddIdentityAttendeeClient(serviceName)wires a typed gRPC client for the extracted Identity service and swapsIAttendeeQueryServiceover toAttendeeQueryServiceGrpcAdapter. - Depends on:
IServiceCollectionandServiceCollectionDescriptorExtensions.Replace; the generatedAttendeeQueryService.AttendeeQueryServiceClient;IAttendeeQueryServiceandAttendeeQueryServiceGrpcAdapter;AddTypedGrpcClient<TClient>fromMMCA.Common.Grpc. - Concept introduced,
Replaceinstead ofTryAdd, 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 usesTryAdd, 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 forIAttendeeQueryService. If the Identity module is enabled it holds the real in-processAttendeeQueryService; if Identity is disabled it holds theDisabledAttendeeQueryServicestub thatIdentityModule'sRegisterDisabledStubsput there so DI validation would still succeed.Replaceoverwrites either one, so after this call the resolved service is the gRPC adapter regardless of which path the host took;TryAddwould silently do nothing in both cases. The consequence is an ordering rule, stated in the same comment (:33-37): call this afterModuleLoader.DiscoverAndRegister(...), so the in-process or stub registration is in the container by the timeReplacelooks 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 athttp://{serviceName}over HTTP/2 cleartext with the standardJwtForwardingClientInterceptorand Polly resilience handler; andservices.Replace(ServiceDescriptor.Scoped<IAttendeeQueryService, AttendeeQueryServiceGrpcAdapter>())(:47), with the inline comment restating theReplace-not-TryAddrule at the point of use (:45-46). It then returnsservicesfor chaining (:49). TheserviceNamedefault 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
.Contractsproject rather than in the consuming service means the knowledge of "how you talk to Identity remotely" lives once, next to the.protothat defines the call, and every future consumer gets it with a project reference plus one line (ADR-007). TheScopedlifetime 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 runsAddApplication(), then every registration callback, thenAddApplicationDecorators(), and seals, soReplaceis guaranteed to see the module's registration and the decorators are guaranteed to wrap what results (:194-214, which repeats theReplacerationale at the call site). The matching AppHost reference is noted atMMCA.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
.Contractsproject'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 frameworkApplicationDbContext. - Depends on:
ApplicationDbContext(base),IEntityConfigurationAssemblyProvider,PhysicalDataSource, EF Core'sDbContextOptions,IServiceProvider, andUser. - 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 (SQLServerDbContextin 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 memberinternal DbSet<User> Users { get; set; }(:22). Note the accessibility:internal, notpublic. Application code reaches users throughIUnitOfWorkand 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 byIEntityConfigurationAssemblyProviderand supplied byUserConfiguration. - 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_Identitydatabase (the connection string the extracted service resolves,MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:52); that database also carries its owndbo.OutboxMessagestable, 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
Useraggregate: column types and widths, the value-object conversion forEmail, the ignored computed properties, and three indexes. - Depends on:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>(base),EmailValueConverter,UserandUserInvariants, and EF Core'sEntityTypeBuilder<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 fromEntityTypeConfigurationSQLServer<User, UserIdentifierType>(UserConfiguration.cs:13) declares the engine, and the database name resolves from the module, which is howUserlands inADC_Identityrather 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 fromUserInvariantsrather than typed twice:EmailMaxLength(:22),FirstNameMaxLength(:29),LastNameMaxLength(:33), andDeviceFieldMaxLengthon 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 theEmailvalue object onto a plain column, thenHasMaxLength,IsUnicode(false)andIsRequired().IsUnicode(false)recurs on every string whose values are ASCII by definition (emails, role names, device identifiers, provider keys, culture codes, URLs): these becomevarchar, notnvarchar, halving the storage. The rule is the split, not a blanket default: human-entered text omitsIsUnicode(false)and so staysnvarchar(M148).- Names (
:26-34):FirstNameandLastName, 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 avarcharcode page. - Device metadata (
:52-78): seven optional, MAUI-only columns (BR-201, BR-202), all bounded byDeviceFieldMaxLength. The machine identifiersDeviceId,DeviceFormFactor,DevicePlatformandDeviceTypestayvarchar, whileDeviceModel,DeviceManufacturerand the user-chosenDeviceNameare free text and staynvarchar(:65-74). - Credentials (
:36-41):PasswordHashandPasswordSaltare configured with no options at all, deliberately. The comment states that EF mapsbyte[]tovarbinary(max)by default so no explicit length is needed (BR-204), and that external-login accounts hold empty arrays. - Fixed-width strings:
Roleat 50 (:43-46),LoginProviderat 50 andProviderKeyat 256 (:80-87),PreferredCultureandPreferredThemeat 10 each (:89-96),AvatarUrlat 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):AcceptedTermsVersionis bounded byTermsVersionMaxLengthandIsUnicode(false)(:109-111), andTermsAcceptedOnis 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, withHasDefaultValue(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,IsExternalLoginandIsLockedare computed on the aggregate, sobuilder.Ignorekeeps EF from expecting columns for them. - Indexes (
:127-138): a unique index onEmailwith no hand-written filter (:130), which is the BR-200 "email is the identity" rule enforced at the storage layer; a unique filtered index onLinkedSpeakerIdwithHasFilter("[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 theEmailindex again: it declaresIsUnique()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):SoftDeleteUniqueIndexConventionwalks 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 sameSoftDeleteFilterSql.Builda 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 andAND-ed with the soft-delete clause (:78-80). So all three indexes here end up excluding deleted rows:Emailgains the clause alone, and the two that spell their ownHasFilterkeep 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
IEntityConfigurationAssemblyProviderand applied when the concrete engine context builds the model declared byModuleApplicationDbContext; 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 withSoftDeleteFilterSql.ContainsPredicateand 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.AnonymizenullsLoginProviderandProviderKeyand rewrites the address to a per-iddeleted-{Id}@anonymized.invalidplaceholder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:507-548);LinkedSpeakerIdis 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 aDbSeeder),IUnitOfWork,IPasswordHasher,SeedAccount,Email,UserandUserRole, andResultin 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; andEmailExistsAsync, because the existence predicate must be written against the concreteUser(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: awhere TUser : ISomethingconstraint would compile and then fail at query translation. - Concept introduced, seeding gated where the gate has one home.
[Rubric §11, Security]. The base exposes aShouldSeedopt-in that defaults totrue(IdentityModuleDbSeederBase.cs:58), and this subclass deliberately does not override it (IdentityModuleDbSeeder.cs:18-20): ADC'sSeeding:IncludeSampleUsersgate stays inIdentityModuleSeederin the API layer, which reads the key withGetValue<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 offalsewhen 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-expressionIReadOnlyList<SeedAccount>with three entries, each(email, password, role, firstName, lastName). The roles come fromUserRole's constants, not string literals.EmailExistsAsync(:41-48): resolves the mutating repository throughUnitOfWork.GetRepository<User, UserIdentifierType>()and callsExistsAsync(u => u.Email == email, ...). The predicate compares theEmailvalue object, which is why the base normalizes the raw string withEmail.Create(account.Email).Valuefirst (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, thenUser.Create(email, firstName, lastName, passwordHash, passwordSalt, role)in ADC's parameter order, returning the aggregate's own genericResult. 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:
SeedAsyncshort-circuits onShouldSeedand 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
IUnitOfWorkand the domain factory rather than through raw SQL or EFHasData, 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 theIModuleSeedercontract 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 plainpublic 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 ➡