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

Onboarding guide

22. ADC Engagement Module (Session Bookmarks)

What this group covers. Engagement is the ADC bounded context that holds everything an attendee does at the conference beyond browsing the catalog. Four capability families live here, and they are worth naming up front because the chapter title only mentions the oldest one: the session-bookmark aggregate (the personal schedule), the QR badge check-in surface (badges, organizer scanning, the manual fallback, attendee self-service from printed room and sponsor codes, and the attendance rollup), the points economy (an append-only ledger plus an opt-in public leaderboard), and the attendee-facing pages the module contributes to the Blazor UI (feedback forms, the badge, the points screens, and the now-next surface). Around those sit the shared module plumbing: use cases, EF persistence, the REST API, the cross-service gRPC contracts and adapters, and the extracted service host. The module also hosts the conference-day live layer (LivePolls and SessionQuestions), a surface large enough that its aggregates, use cases, controllers, wire payloads and pages are documented on their own in Group 23. What stays in this chapter is everything the rest of the module shares with that layer: the flags in EngagementFeatures, the grants in EngagementPermissions, the outbound publish queue behind ILiveChannelPublishQueue, the two UI helpers the conference-day pages share (LiveChannelSubscription and LiveBroadcastPatch), the browser-side client layer those pages call (ILiveEventUIService, ILivePollUIService and ISessionQuestionUIService with their implementations), the session catalog lookup behind ISessionLookupService, and the LivePolls table shapes (LivePollConfiguration, LivePollOptionConfiguration and LivePollVoteConfiguration), which sit beside the other EF configurations of the one database this module owns. Read Group 02 for the entity primitives and primer §2 for the styles before you go deep here.

Five small aggregates, one shape. Every write in this module lands on an AuditableAggregateRootEntity<TIdentifierType> with private setters, private constructors, and a static Create factory returning Result<T> after a Result.Combine over a matching invariants helper. UserSessionBookmark (MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:16) carries two scalar foreign keys, UserId and SessionId (:19, :22), and raises one UserSessionBookmarkChanged event with a DomainEntityState discriminator instead of separate Created and Deleted events (:57, :73, :88, documented in-code as BR-60); its Reactivate() (:68-76) undeletes a soft-deleted row and re-raises the Added variant, which is the domain half of the re-bookmarking rule BR-135. The comment at :55-56 is worth reading before you copy the event: the bookmark's identity is generated by the INSERT, so the event captures a zero id by value and consumers correlate on the user and the session instead. CheckIn (MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:28) is one aggregate for all three scopes of the CheckInScope enum, because the row, the idempotency rule, and the attendance query have the same shape for each and only the required target differs (:13-15); its CheckedInByUserId (:49) is what keeps a self-recorded scan distinguishable from an organizer's, and it is the one aggregate here that raises an integration event straight from its factory (:112). AttendeeBadge (MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:18) holds one opaque Guid credential per user (:24) and raises no domain event at all, since a badge existing is not a fact any other module reacts to (:12-15); Regenerate() (:59) revokes every printed or screenshotted copy, and the credential is minted with Guid.NewGuid() rather than Guid.CreateVersion7() precisely because a v7 value embeds a timestamp and orders monotonically, which is the one property a bearer credential must not have (:65-68). PointsEntry (MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:31) is an append-only ledger row with no mutators, so a total is always the sum of its entries (:12-13), and it snapshots the configured award at earn time (:39-40) so retuning the economy never rewrites history. LeaderboardOptIn (MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:19) stores the display name the attendee explicitly published (:35), which is why serving the board needs no call into Identity, and it keeps EraseDisplayName() (:130) deliberately separate from Delete() (:109): leaving the board and erasing the name you published there are two different promises, and only the second is irreversible. CheckIn and PointsEntry additionally declare IAuditedEntity (CheckIn.cs:28, PointsEntry.cs:31), so their field-level change history is written by the audit-trail interceptor (ADR-075): an attendance assertion about a named person and a prize-bearing ledger are both things you want to be able to prove rather than merely assert.

The bookmark slice, end to end. BookmarksController (MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:34) is the thin REST edge: each action injects a handler and delegates, mapping Result failures to RFC 9457 Problem Details through ApiControllerBase. The POST does its own ownership bind first, because the owner arrives in the request body rather than in a query argument: a caller without the Organizer role may only create a bookmark whose UserId equals their user-identifier claim, otherwise the action returns Error.Forbidden (:63-70, ADR-033). It also carries IdempotentAttribute (:50), which is safe here for the reason stated in the doc comment: the request names the (user, session) pair explicitly and a duplicate already answers 409, so replaying a retried star is what the attendee meant (ADR-017). It then routes into CreateBookmarkHandler (MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:16), still the most instructive slice in the module: it asks Conference's ISessionBookmarkValidationService whether the session may be bookmarked at all (:31, BR-49 and BR-91 across the service boundary), returns Error.Conflict on an existing active bookmark (:37-47, BR-21), loads any soft-deleted row through the repository's FindIncludingDeletedAsync (:51-55) and hands the create-or-reactivate decision to the pure BookmarkManagementDomainService behind IBookmarkManagementDomainService (:57), so BR-135 lives in the domain instead of being smeared across the handler. Only a genuinely new entity is added to the repository (:63-66), and the save is wrapped in a catch filtered by the framework's injected IUniqueConstraintViolationDetector (:71), so a request that loses the insert race against the filtered unique index sees the same 409 the pre-check would have produced rather than a raw 500 (:72-83). That detector is the module's shared answer to "the database is the rule, the pre-check is only a courtesy": PointsAwarder, GetOrCreateMyBadgeHandler and SetLeaderboardParticipationHandler inject the identical abstraction. On the read side, GetUserBookmarksHandler (.../GetUserBookmarks/GetUserBookmarksHandler.cs:17) returns a paged, CreatedOn-descending PagedCollectionResult<T> of DTOs, with the page size clamped by PagingMath before anything touches the database and ordering plus OFFSET/FETCH pushed into SQL through IQueryableExecutor; GetBookmarkedSessionIdsHandler (.../GetBookmarkedSessionIds/GetBookmarkedSessionIdsHandler.cs:12) is the lightweight sibling the unified Sessions page turns into per-row filled or empty bookmark icons. Both list endpoints are scoped by the shared OwnerOrAdminFilter applied as a [ServiceFilter] (BookmarksController.cs:84, :105), configured for ADC through OwnerOrAdminFilterOptions in AddModuleEngagementAPI (MMCA.ADC.Engagement.API/DependencyInjection.cs:45-56): ClaimTypes.NameIdentifier as the owner claim, the userId query argument, and the Organizer bypass role. The DELETE keeps a separate inline, database-backed ownership check that returns 404 rather than 403 so it never leaks whether a bookmark exists (BookmarksController.cs:131-148), then runs the generic DeleteEntityCommand<TEntity, TIdentifierType> (:151), which soft-deletes.

Check-in: four ways in, one row out. CheckInsController (MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:39) exposes the whole surface behind [FeatureGate(EngagementFeatures.CheckIn)] and a plain [Authorize] (:33-34). An attendee fetches their own badge at GET my-badge with no permission at all (:46), because GetOrCreateMyBadgeHandler (.../GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:17) reads the identity from the token and mints on first use (:38-70), returning a MyBadgeDTO carrying nothing but the credential (:72-74). The QR itself is formatted by BadgePayload (MMCA.ADC.Engagement.Shared/CheckIns/Badges/BadgePayload.cs:12), one place that owns both the mmca-adc:badge: prefix (:15) and the parser (:30), so the display side and the check-in handler cannot drift. The organizer paths are gated by [HasPermission(EngagementPermissions.CheckInManage)] (CheckInsController.cs:75, :99, :179): CheckInAttendeeHandler (.../CheckInAttendee/CheckInAttendeeHandler.cs:19) resolves the scanned credential to a badge row and answers a malformed payload and an unknown-but-well-formed credential with the same error, since a scanner that could tell them apart would be an oracle for guessing credentials (:41-56), and ManualCheckInHandler covers the dead-phone fallback. Both then run the shared CheckInProcessor (MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:23), a static helper rather than an injected service so neither handler gains a dependency it does not own (:11-14). The processor is split in two halves on purpose: ExecuteAsync (:109) is the organizer path, which validates the target through Conference and lets the server's answer for the owning event win over the value the scanning client sent (:124-128), while RecordAsync (:58) is the write half every entry point shares, short-circuiting a repeat scan into a success carrying the original timestamp rather than a conflict (:71-75) and otherwise recording exactly one row via the RecordedCheckIn outcome struct (:38). The two self-service flows are separate handlers that reuse only that write half. RecordSponsorVisitHandler (.../RecordSponsorVisit/RecordSponsorVisitHandler.cs:34) and RecordRoomCheckInHandler (.../RecordRoomCheckIn/RecordRoomCheckInHandler.cs:26) take the attendee from the token, never the request, and each carries its own feature flag (CheckInsController.cs:130, :161) so a printed sponsor code and a printed room code can be retired independently. The room flow is the sharpest piece of design here: only the room travels in the request, and the server resolves which session that room is hosting at the request instant plus a configured grace window (RecordRoomCheckInHandler.cs:50-52), so a shared link records nothing outside a session's window and nobody can name a session they are not standing in; an unknown room and an idle room answer identically so the response never confirms which room ids exist (:55-63). GetAttendanceStatsHandler (.../GetAttendanceStats/GetAttendanceStatsHandler.cs:15) closes the loop for organizers, and both of its figures are computed in SQL: a CountAsync for the event scope and a CountByAsync grouped per session, so what crosses the wire is one row per session rather than one per check-in (:27-34). All of this is ADR-072; the camera half of the scanner is ADR-071.

Points: one write path, several adapters. Everything that earns goes through IPointsAwarder / PointsAwarder (MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:29), which takes an (activity, subjectKey) pair and knows nothing about events, sessions or sponsors. It reads the configured value from PointsSettings (MMCA.ADC.Engagement.Shared/Points/PointsSettings.cs:12) and treats a configured or missing 0 as a per-rule kill switch, writing nothing rather than a zero-value ledger row (PointsAwarder.cs:43-51); it pre-checks for an existing award purely to make the common duplicate path a clean no-op (:55-64); and it reads the unique-index violation as already awarded, not as a failure to retry (:75-82). The rule that matters is the index, not the code: the unique (UserId, ActivityType, SubjectKey) index in PointsEntryConfiguration (MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/Points/PointsEntryConfiguration.cs:45-48) is simultaneously the idempotency guarantee and the anti-farming rule, because PointsSubjectKeys collapses N questions or N answers in one session onto one subject key. Four adapters feed it. Three are integration-event handlers built on ScopedIntegrationEventHandlerBase<TIntegrationEvent>: AttendeeCheckedInPointsHandler (.../IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:31) maps the wire scope string onto an earn rule and logs-and-skips a scope this build has never heard of rather than dead-lettering a message no retry could fix (:42-46, :65-99), while SessionFeedbackSubmittedPointsHandler and EventFeedbackSubmittedPointsHandler consume events Conference publishes. The fourth, SessionQuestionSubmittedPointsHandler (.../DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:51), rides an in-process domain event instead, an explicit at-most-once trade-off documented in the type: a crash between the commit and the dispatch loses one small award and nothing else, and promoting it to an integration event later is a one-file change on each side (:30-39). Its doc comment is also the best worked example in the module of the id-by-value trap described above, and of why every field it needs now comes off the event rather than from a read-back (:21-29). A fifth handler earns nothing at all: UserDeletedPointsHandler (.../IntegrationEventHandlers/UserDeletedPointsHandler.cs:37) consumes Identity's UserDeleted and erases the published leaderboard name, reading with ignoreQueryFilters: true precisely because an attendee who already left the board still has a soft-deleted row carrying the name that must be erased (:53-61).

Reading the points back. PointsController (MMCA.ADC.Engagement.API/Controllers/PointsController.cs:36) is a good study in designing an ownership problem out of existence: nothing on the surface takes a user id, so the three attendee endpoints resolve the caller from the token inside their handlers and there is no ownership check to get wrong (:25-29). GetMyPointsHandler serves the caller's own total and a page of the entries behind it; SetLeaderboardParticipationHandler (.../SetLeaderboardParticipation/SetLeaderboardParticipationHandler.cs:30) takes the published display name from the caller's token claims server-side, which is why SetLeaderboardParticipationRequest carries only a boolean (:14-19), and rejoining reactivates the soft-deleted opt-in rather than accumulating dead rows (:20-24). GetLeaderboardHandler (.../GetLeaderboard/GetLeaderboardHandler.cs:28) builds the board from the opt-in list and the ledger with no call into Identity (:12-17), projecting the opt-ins to the two columns it needs through the private OptInRow record (:40-43, :85) and summing the ledger with one grouped SumByAsync in the database rather than pulling every row back to fold in memory (:50-59). Ranks are distinct and sequential with an ordinal name tie-break, so repeated reads return the same order (:18-24, :65-77). Only the organizer rollup carries a permission, [HasPermission(EngagementPermissions.PointsViewOverview)] (PointsController.cs:121), and even that view returns activity, points and timestamps without attendee identity (:114-119).

Persistence. Engagement's ModuleApplicationDbContext (MMCA.ADC.Engagement.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19) is an abstract context declaring the module's ten internal DbSets (bookmarks, badges, check-ins, the three live-poll sets, the two question sets, the points ledger and the opt-ins, :27-54) and inheriting auditing, soft-delete query filters and domain-event dispatch from the common ApplicationDbContext (:24); the concrete per-engine class (SQLServerDbContext today) sits under it, one instance per physical database (ADR-006). The table shapes are where the module's business rules become enforceable, and every configuration derives from EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>. UserSessionBookmarkConfiguration (.../UserSessionBookmarkConfiguration.cs:17) makes BR-21 a filtered unique index over (UserId, SessionId) restricted to non-deleted rows (:31-34) and adds a SessionId-leading index the composite cannot serve (:36-39). CheckInConfiguration (.../CheckInConfiguration.cs:19) carries three filtered unique indexes, one per scope, with the scope literals kept side by side as named constants because an index filter is SQL and cannot read the enum (:22-26, :48-62); the sponsor one is the once-per-sponsor cap that makes a shared booth link worth nothing past the first scan (:58-62), and two more indexes serve the attendance rollup (:64-69). PointsEntryConfiguration adds the UserId-leading index that makes "my points" a seek (:50-52). The live-poll tables follow the same discipline even though their aggregates are taught in Group 23: LivePollVoteConfiguration (.../LivePolls/LivePollVoteConfiguration.cs:17) makes BR-225, one active vote per (LivePollId, UserId), a filtered unique index through the shared HasSoftDeleteFilter() helper (:34-37), which is the database-level guarantee behind the vote handler's create-or-reactivate dance (:12-15), and adds the (LivePollId, OptionId) index the tallies group by (:39-40). LivePollConfiguration (.../LivePolls/LivePollConfiguration.cs:16) indexes EventId for the Happening Now and organizer manage views (:34-35) and (SessionId, Status) for the session Live page and presenter view, the conference-day hot path that previously scanned (:37-41), and pins the Options navigation to PropertyAccessMode.Field so the encapsulated collection cannot silently stop materializing if the property changes shape (:43-50). LivePollOptionConfiguration (.../LivePolls/LivePollOptionConfiguration.cs:10) is the plain child-entity case: a required text column bounded by the domain's own LivePollInvariants.OptionTextMaxLength and the required FK back to its poll (:18-25). Cross-module identifiers (UserId, EventId, SessionId, SponsorId) are scalar columns throughout, never cross-database foreign keys (CheckInConfiguration.cs:16-17). The module owns ADC_Engagement and is its sole migrator, applying EF migrations idempotently at startup (MMCA.ADC.Engagement.Service/Program.cs:305-310, ADR-030), so events serialized into its own outbox during SaveChangesAsync never race another service's rows (ADR-003).

How Engagement talks to its neighbors. Engagement is both a client and a server on the gRPC mesh (ADR-007, ADR-008). As a server, its in-process BookmarkCountService (MMCA.ADC.Engagement.Application/UserSessionBookmarks/Services/BookmarkCountService.cs:11) answers "how many people bookmarked this session" for Conference's speaker pages, including a batched variant that replaces the caller's per-session fan-out with one grouped COUNT and back-fills a zero for every requested session that has none (:37-46). It goes on the wire through BookmarkCountsGrpcService (MMCA.ADC.Engagement.Service/Grpc/BookmarkCountsGrpcService.cs:24) and is consumed through the client-side BookmarkCountServiceGrpcAdapter (MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:14), which re-implements the same C# interface so calling code never knows it crossed a process. Read the trust-boundary note before copying the pattern: that endpoint is mapped deliberately without RequireAuthorization(), because the Conference callers serve [AllowAnonymous] output-cached endpoints and a cache-miss request from an anonymous visitor carries no bearer to forward; the exposure is bounded by the payload (aggregate counts, no PII, no user ids) and by an internal-only container ingress (MMCA.ADC.Engagement.Service/Program.cs:325-334). The identical bridge carries the privacy export, and there the choice flips. UserEngagementExportService (MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:17) projects a user's bookmarks, submitted questions, points ledger, check-in history and leaderboard participation server-side into a UserEngagementExportDTO of ids and dates (:24-81), with soft-deleted rows excluded by the global query filter; check-ins are exported independently of the ledger, because a switched-off rule or a deduped repeat scan means the ledger is no proxy for the attendance record a data subject is entitled to (:48-50). It is served by UserEngagementExportGrpcService and consumed by Identity through UserEngagementExportServiceGrpcAdapter, and that endpoint does carry RequireAuthorization() because it returns personal data keyed by a raw user id (Program.cs:335-341, PRIVACY.md §7, ADR-076, ADR-005). The Contracts-layer DependencyInjection (MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:16) is what swaps in-process for remote: each helper registers the typed client and then Replaces (never TryAdds) the existing registration, so it overwrites both the real service and the disabled stub (:45-51), and it must run after ModuleLoader.DiscoverAndRegister (:36-39).

The outbound live-channel path. Engagement never blocks a command on Notification. Handlers enqueue a post-commit broadcast as a LiveChannelPublishWorkItem (MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:10) through ILiveChannelPublishQueue (:21), a three-string record (channel key, event name, pre-serialized payload) carrying no entity and no per-user data. LiveChannelPublishQueue (MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:14) is the single concrete channel, registered once as a singleton and exposed to handlers via the interface (MMCA.ADC.Engagement.Application/DependencyInjection.cs:56-57). Read its backpressure contract carefully: the channel is bounded at 1024 items (:18) with BoundedChannelFullMode.DropOldest and SingleReader (:33-40), so Enqueue returns void and never rejects an item (it evicts the oldest rather than refusing the write, :50-58), and a discarded broadcast is observable only through DroppedCount (:47), the itemDropped callback, and a warning log. LiveChannelPublishProcessor (MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30) is the single-reader BackgroundService drain, registered as the module's only hosted service (MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:21), forwarding items to ILiveChannelPublisher in FIFO order: one reader preserves per-session ordering, and the swallow is BestEffort's rather than a hand-rolled catch, so a peer that has quietly stopped accepting broadcasts is countable on the besteffort.dispatch.failed meter instead of being a warning nobody alerts on (:21-28, :45). What actually gets published is taught in Group 23.

Module wiring, feature gating, and the extracted host. EngagementModule (MMCA.ADC.Engagement.API/EngagementModule.cs:14) is the IModule entry point discovered by ModuleLoader and registered in topological order; it declares a dependency on Conference with RequiresDependencies => true (:20, :23), and Register is a one-liner into AddEngagementModule (:26-27), which chains the Application, Infrastructure and API registrations in dependency order (MMCA.ADC.Engagement.API/DependencyInjection.cs:26-28). The Application registration is where the module's own services land: the bookmark domain service as a singleton (MMCA.ADC.Engagement.Application/DependencyInjection.cs:38), the two cross-module services scoped (:45, :49), the awarder scoped and registered by hand because it is a plain service the convention scan does not see (:75-83), and everything conventional (handlers, mappers, validators, event handlers) discovered by ScanModuleApplicationServices<ClassReference>() (:87), which is where the per-layer ClassReference marker earns its keep. Each of the four layers declares its own AssemblyReference / ClassReference pair, and the design-time migrations factory reaches for the Infrastructure one, because the IEntityTypeConfiguration classes live in Infrastructure rather than Application. When the module is disabled in a host, RegisterDisabledStubs swaps in DisabledBookmarkCountService and DisabledUserEngagementExportService (EngagementModule.cs:30-34) so a Conference-only or Identity-only host still resolves those interfaces. Every capability has its own kill switch in EngagementFeatures (MMCA.ADC.Engagement.Shared/EngagementFeatures.cs:8): SessionBookmarks, LivePolls, CheckIn, SponsorVisits, RoomCheckIn, Points and SessionQA (:15-58), each turning its controller or route into a 404 without a deploy. Authorization above authentication is capability-based, not role-based: EngagementPermissions (MMCA.ADC.Engagement.Shared/Authorization/EngagementPermissions.cs:9) declares three capabilities, engagement:live:manage, engagement:checkin:manage and engagement:points:view-overview (:16, :23, :30), granted wholesale to Organizer and Admin in AddModuleEngagementAPI (MMCA.ADC.Engagement.API/DependencyInjection.cs:58-62, ADR-020). MMCA.ADC.Engagement.Service boots exactly this one module and is worth reading top to bottom as the reference shape for an extracted host. Kestrel is configured in one line, builder.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2) (Program.cs:67), the shared helper from KestrelEndpointExtensions: Http2-only on cleartext so cross-service gRPC clients negotiate h2c by prior knowledge, plus the optional HTTP/1.1-only health-probe listener (ADR-012). PointsSettings and CheckInSettings are bound here rather than in the module, because the module registration takes no IConfiguration, and both are deliberately not ValidateOnStart: a missing section binds working defaults and an explicit 0 is the documented kill switch, so no value an organizer sets should stop this host from booting mid-conference (:122-133, a considered divergence from ADR-070). The scheduler and audit trail are wired next (:193, :197, ADR-074, ADR-075), and EngagementErrorResources contributes the module's error-code translations (:216, ADR-027). Composition itself runs through AddMmcaApplicationPipeline, a single ordered chain that registers the modules first and then, because Conference is disabled in this process, re-points Engagement's two Conference dependencies at gRPC clients, swaps the framework's no-op live-channel publisher for the Notification adapter, and finally wires broker messaging (:278-288, ADR-014 closes and seals the decorator pipeline at the end of it). This host is not publisher-only: the points game consumes four integration events, AttendeeCheckedIn, SessionFeedbackSubmitted, EventFeedbackSubmitted and UserDeleted (:285-288). AttendeeCheckedIn is the novel one: it is published by this same service (the CheckIn factory raises it and the outbox captures it in the check-in transaction), so this is ADC's first broker self-consumption, which MassTransit's type topology supports with no special casing (:268-271). SelfHttpWarmupTask (MMCA.ADC.Engagement.Service/SelfHttpWarmupTask.cs:23) extends SelfHttpWarmupTaskBase and holds /health/ready not-ready until the routing, auth and middleware pipeline has been JITted (Program.cs:153-157, ADR-025); the two gRPC endpoints are mapped last (:333, :341).

The UI surfaces. EngagementUIModule (MMCA.ADC.Engagement.UI/EngagementUIModule.cs:17) implements IUIModule to contribute the module's Blazor assembly (:33), the invisible LiveEventListener layout component (:31), and six nav items declared as resource keys with the organizer ones carrying a RequiredRole so the shared NavMenu hides them from attendees (:21-29, ADR-027); their targets live in EngagementRoutePaths (MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:8), which also holds the two deep-link-only self-service routes that deliberately contribute no nav item at all (:29-32). The pages divide by actor. Attendees get MyBadge (Pages/CheckIn/MyBadge.razor.cs:15), which renders the opaque payload beside the account name as plain text so a human can confirm the badge belongs to its holder (:10-13), MyPoints, and the two QR landing pages SponsorVisit (Pages/Sponsors/SponsorVisit.razor.cs:21) and RoomCheckIn, which post the write themselves so the whole interaction is one scan with nothing to press; both keep the write off the prerender pass and behind a once-per-instance guard (SponsorVisit.razor.cs:48-55) and branch their on-screen state off the server's stable error code carried back by SelfCheckInOutcome<TResult> (Services/SelfCheckInOutcome.cs:20) and named in CheckInErrorCodes (Services/CheckInErrorCodes.cs:8), because two of the three refusals share the same 404 status. Organizers get CheckInScan (Pages/CheckIn/CheckInScan.razor.cs:18), which loops the shared scanner where a camera exists and falls back to the AttendeeSearchPanel name and email search everywhere else (:12-16, :38), OrganizerAttendance and OrganizerPointsOverview. The two feedback pages, EventFeedback (Pages/Feedback/EventFeedback.razor.cs:19) and SessionFeedback (Pages/Feedback/SessionFeedback.razor.cs:18), render a dynamic question form whose definitions and answers belong to Conference, with per-question values held in a FeedbackAnswerModel (Pages/Feedback/FeedbackAnswerModel.cs:23) whose DataAnnotations are the single declaration of the 4000-character free-text rule (:30-34). Their submit paths differ, and the difference is deliberate: the event form posts one upsert per answered question and reports exactly how much was saved when a call fails (EventFeedback.razor.cs:183-202, BR-107), while the session form posts the whole form in one call the server applies atomically (SessionFeedback.razor.cs:226, Services/IFeedbackUIService.cs:57-61). Behind the pages sit ordinary authenticated HTTP clients built on AuthenticatedServiceBase: BookmarkService and the cross-module SessionBookmarkUIService (which also feeds SessionReminderCoordinator and the pure SessionReminderPlanner, whose SessionReminder records carry a stable notification id so rescheduling replaces rather than duplicates, Services/SessionReminderPlanner.cs:13, :29-38, ADR-042), CheckInService, PointsService, AttendeeLookupService (whose by-identifier lookup is answered from a roster snapshot because the Identity users endpoint filters by name and email but cannot be queried by identifier, Services/AttendeeLookupService.cs:10-15), and NowNextService behind INowNextService, which mirrors the Conference API's now-next wire shape locally rather than referencing Conference.Shared for one payload (Services/INowNextService.cs:6-9). The conference-day pages taught in Group 23 get their client layer from here too, and it is the same shape. LivePollUIService (MMCA.ADC.Engagement.UI/Services/SessionLive/LivePollUIService.cs:15) and SessionQuestionUIService (Services/SessionLive/SessionQuestionUIService.cs:15) are AuthenticatedServiceBase clients over the Gateway's livepolls and sessionquestions routes (:19 in each), and their contracts ILivePollUIService (Services/SessionLive/ILivePollUIService.cs:15) and ISessionQuestionUIService (Services/SessionLive/ISessionQuestionUIService.cs:15) both open with the rule that keeps a live page calm: every member answers with a Result, so a server refusal is data the page renders rather than an exception, and only the caller's own cancellation still propagates (ILivePollUIService.cs:10-13, ISessionQuestionUIService.cs:10-13). Both also say in the contract that the API enforces the manage and moderation rights regardless of what the UI chooses to render (ILivePollUIService.cs:7-9, ISessionQuestionUIService.cs:7-9), which is the only safe way to write a doc comment about a permission a browser can be told to ignore. LiveEventService behind ILiveEventUIService (Services/SessionLive/LiveEventService.cs:14, Services/SessionLive/ILiveEventUIService.cs:7) resolves the currently-live-or-next published event from the Conference API and computes its window with the same math the backend enforces, then degrades to null on any API failure so the live layer simply stays dormant instead of erroring a page (LiveEventService.cs:7-12, :33-36); the LiveEventContext record it returns (Services/SessionLive/LiveEventContext.cs:13) answers IsLiveAt and ToEventLocal for the pages, and its zone id always resolves because the Conference write path guards it (:20-31). SessionLiveUIService (Services/SessionLive/SessionLiveUIService.cs:10) is the smallest type in the chapter and the clearest one about module boundaries: it implements the ISessionLiveUIService extension point with a single line returning Engagement's own route (:13-14), so a Conference session page lights up its Live button only when this module is present. SessionLookupService behind ISessionLookupService (Services/Lookups/SessionLookupService.cs:13, Services/Lookups/ISessionLookupService.cs:19) is the enrichment client that turns session ids into the titles and times the bookmark surfaces display, projecting the Conference page into a session-keyed dictionary of the small SessionInfo record (ISessionLookupService.cs:9, SessionLookupService.cs:33-43); its contract carries the usage rule as well as the signature, because the catalog call is cheap to misuse: GetAllAsync is for a page that genuinely needs every session (the reminder planner, which schedules against every bookmarked one), and a single-session page must call GetByIdAsync rather than transfer the catalog to label one row (ISessionLookupService.cs:21-28). The comment above the fetch is worth keeping when you copy it: the base sessions endpoint takes no page size and always serves one page capped at the framework's 500-row maximum, which covers a conference catalog (SessionLookupService.cs:24-25). One more UI type is easy to miss and easy to get wrong: CurrentEventNotificationScopeProvider (Services/CurrentEventNotificationScopeProvider.cs:29) scopes ADC's notifications to event:{EventId}, caching a resolved key for five minutes because the bell polls every 30 seconds (:40), and it never answers null: on INotificationScopeProvider a null key means unscoped, which would widen an attendee's inbox to every event, so an instance that has never resolved an event answers with a well-formed key no row carries (:18-24, :34-38), failing to an empty inbox rather than to everyone's.

Rubric lens. This module is a compact end-to-end illustration of most of Part A. Five small aggregates whose rules live in factories and invariants are the [Rubric §4, Domain-Driven Design] story; the controller-handler-domain-service split and the one-slice-per-folder layout are [Rubric §5, Vertical Slice] and [Rubric §6, CQRS and Event-Driven], with the points adapters showing both dispatch styles (broker integration events and an in-process domain event) side by side and stating the trade-off in code. The gRPC adapters, disabled stubs and Replace-based composition are [Rubric §7, Microservices Readiness] and [Rubric §9, API and Contract Design]; the per-service database, the filtered unique indexes carrying idempotency, anti-farming and one-vote-per-poll, and the pushed-down grouped COUNT and SUM in the attendance and leaderboard reads are [Rubric §8, Data Architecture] and [Rubric §12, Performance and Scalability]. Security ([Rubric §11]) is the recurring theme rather than a bolt-on: identity taken from the token instead of the body on every self-service path, the 404-not-403 delete, indistinguishable answers for a bad payload and an unknown credential, server-resolved room sessions, a bearer credential minted from a non-monotonic Guid, and one documented unauthenticated-by-necessity gRPC endpoint. The drop-oldest publish queue with its dropped-count meter is [Rubric §29, Resilience] and [Rubric §13, Observability and Operability], as is the live client layer's Result-only surface and its dormant-rather-than-broken answer when the event lookup fails; per-capability feature flags plus a retunable points economy with per-rule kill switches are [Rubric §12, Performance & Scalability] and [Rubric §17, DevOps]; the export projection and the leaderboard name erasure are [Rubric §30, Compliance and Privacy]. The pages extend the story into Part B ([Rubric §18, UI Architecture], [Rubric §24, Forms/Validation/UX Safety] for the single-declaration answer model, and [Rubric §25, Navigation and IA] for the deep-link-only QR routes). Relevant ADRs, roughly in the order they surface above: ADR-075 (audit trail), ADR-033 (owner-or-admin filter), ADR-017 (request idempotency), ADR-001 (Mapperly mapping), ADR-072 and ADR-071 (badge check-in, points, and the scanner capability), ADR-006 and ADR-030 (database-per-service and sole migrator), ADR-003 (outbox), ADR-007 and ADR-008 (gRPC extraction and topology), ADR-076 and ADR-005 (data-subject export, soft-delete versus erasure), ADR-039 (best-effort live publish), ADR-020 (permission registry), ADR-014 (the sealed decorator pipeline the host closes), ADR-012 (Kestrel endpoint profile), ADR-070 (fail-fast configuration, and this module's documented exception to it), ADR-074 (scheduler), ADR-025 (warm-up and readiness), ADR-027 (resource-key navigation and error translations), and ADR-042 (device capabilities and reminder dispatch).

AssemblyReference

MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:5 · Level 0 · class (static)

One byte-identical copy per Engagement layer. This section covers the API and Application copies; the Domain, Infrastructure, Shared, and UI copies are the same type in another part of this chapter.

  • What it is: a tiny static class that exposes the assembly it lives in (Assembly) plus that assembly's simple name (AssemblyName), so anything that needs to say "scan the assembly this type belongs to" has a compiled, refactor-safe anchor instead of a namespace string.

    Type File:Line Notes (what differs)
    AssemblyReference (API) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:5 namespace MMCA.ADC.Engagement.API (:3)
    AssemblyReference (Application) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:5 namespace MMCA.ADC.Engagement.Application (:3)

    Nothing else differs: both files are eleven lines long and declare the same two fields at the same line numbers.

  • Depends on: System.Reflection.Assembly (BCL) only, imported at line 1 of each file. No first-party dependencies, which is why both copies sit at Level 0.

  • Concept, assembly-marker types for convention scanning. The framework-level explanation lives with the shared copy in AssemblyReference; these are the Engagement-local instances of the same idiom. [Rubric §2, Design Patterns] assesses whether recurring problems are solved with a recognized idiom: handing an Assembly to a scanner through a purpose-built token, rather than through some incidental business class, is that idiom. [Rubric §1, SOLID] (Dependency Inversion) applies too: registration and configuration code depends on this deliberate marker, not on typeof(SomeHandler).Assembly, so renaming or moving a real type never silently breaks a scan.

  • Walkthrough: two public static readonly fields, resolved once at type initialization and identical in both copies. Assembly (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:7, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:7) captures typeof(AssemblyReference).Assembly. AssemblyName (line 8 of each file) reads Assembly.GetName().Name ?? string.Empty, the null-coalesce keeping the field non-null under nullable-reference-type analysis even if the runtime reports no simple name. There are no methods and no other state.

  • Why it's built this way: each layer can be scanned from inside itself without a cross-layer reference, and a compiled typeof cannot go stale the way a hardcoded assembly-name string would. The Engagement module declares the pair once per scannable layer, which is why the same two type names recur across the chapter.

  • Where it's used: the convention scan the Application layer runs takes the companion ClassReference rather than the static class, because a static class cannot be a generic type argument (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88). The static handle is the shape used where an Assembly value is passed by hand, for example the design-time EF factories and the architecture-fitness map.

  • Caveats / not-in-source: neither of these two copies has a reader inside the Engagement API or Application projects themselves; they exist so that every layer carries the same anchor.


ClassReference

MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:11 · Level 0 · class

One byte-identical copy per Engagement layer. This section covers the API and Application copies.

  • What it is: the non-static companion to AssemblyReference, an empty instantiable class declared at the bottom of the same file. It supplies an assembly anchor for the APIs that need a generic type argument, which a static class can never satisfy.

    Type File:Line Notes (what differs)
    ClassReference (API) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/AssemblyReference.cs:11 namespace MMCA.ADC.Engagement.API
    ClassReference (Application) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/AssemblyReference.cs:11 namespace MMCA.ADC.Engagement.Application
  • Depends on: nothing first-party, and nothing from the BCL beyond object.

  • Concept: the companion half of the marker pattern, introduced at framework level under ClassReference. C# forbids a static class as a generic type argument, and the registration helper the modules use takes its marker as a type parameter, so a second, non-static token fills that slot without weakening AssemblyReference's static-ness. [Rubric §33, Developer Experience] assesses how much ceremony the inner loop costs: one conventional token per layer is the whole of it.

  • Walkthrough: public class ClassReference { } at line 11 of both files. No members, no behavior; its only meaningful property is the assembly it belongs to, read as typeof(ClassReference).Assembly by a scanner.

  • Why it's built this way: keeping the two forms separate sidesteps the static-class generic-argument restriction while leaving AssemblyReference impossible to instantiate by accident. Every layer declares its own copy so it scans itself with a local token.

  • Where it's used: the Application-layer copy is the one with a call site: services.ScanModuleApplicationServices<ClassReference>() in the Application DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88), which registers domain event handlers, DTO and request mappers, command and query handlers, and validators by convention.

  • Caveats / not-in-source: the API-layer copy has no reader in the repo; it exists to keep the per-layer convention uniform.


EngagementErrorResources

MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Resources · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Resources/EngagementErrorResources.cs:9 · Level 0 · class (sealed)

  • What it is: an empty sealed class that serves as the resource anchor type for the Engagement module's localized error-code translations. It carries no members; its job is to name the .resx family that sits beside it.
  • Depends on: nothing first-party in code. Its behavioral partners are the shared ErrorLocalizer infrastructure (through the IErrorLocalizer contract) and the AddErrorResources<T>() host registration helper, both named in the type's doc comment.
  • Concept, resource-anchor types for per-module error localization. [Rubric §27, Internationalization] assesses whether user-facing strings are externalized and culture-resolved rather than hard-coded. The doc comment (EngagementErrorResources.cs:3-8) states the mechanism: the type's .resx siblings (EngagementErrorResources.resx for the invariant culture, EngagementErrorResources.es.resx for Spanish) are keyed by a domain error's Code, and the shared IErrorLocalizer resolves a failing Result's code to a translated message once the host registers this type via AddErrorResources<EngagementErrorResources>(). That is ADR-027 applied per module: the class is empty because a resource anchor only needs a stable full name from which the resource base name is computed.
  • Walkthrough: public sealed class EngagementErrorResources with an empty body (EngagementErrorResources.cs:9-11), no members. sealed because it is never meant to be subclassed; it exists purely as a typeof handle for the resource manager.
  • Why it's built this way: one anchor per module keeps error strings out of a single monolithic resource file, so each bounded context owns its own translation table (ADR-027). The type lives in the API layer because that is where errors become HTTP responses.
  • Where it's used: registered by the Engagement service host through AddErrorResources<EngagementErrorResources>(); read by the shared ErrorLocalizer when mapping a domain error code to a message. The codes it translates are the ones the handlers raise, for example CheckIns.Forbidden and CheckIns.BadgeConflict in GetOrCreateMyBadgeHandler and CheckIns.EventNotPublished in CheckInProcessor.
  • Caveats / not-in-source: the .resx files themselves and the host registration call live outside this .cs file; only the anchor type is verified here, the rest is quoted from its doc comment.

GetAttendanceStatsQuery

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetAttendanceStats · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetAttendanceStats/GetAttendanceStatsQuery.cs:8 · Level 0 · record (sealed)

  • What it is: the read request behind the organizer dashboard's attendance rollup for one event: how many people arrived at the event, plus a count for each session that has at least one session-scoped check-in.
  • Depends on: the EventIdentifierType alias only (an identifier type alias linked solution-wide, see primer §4). Paired with GetAttendanceStatsHandler through the query contract, and answered as an AttendanceStatsDTO.
  • Concept, the CQRS query as an immutable record. This is a vertical-slice read: the query type and its handler live in the same UseCases/GetAttendanceStats/ folder. [Rubric §6, CQRS & Event-Driven] assesses whether reads and writes are modeled as distinct messages; this is a pure read, so the pipeline runs it without a transaction. [Rubric §5, Vertical Slice] assesses feature-folder cohesion, which the layout embodies. record class gives value equality and immutability for free, so the message is safe to pass and compare.
  • Walkthrough: public sealed record class GetAttendanceStatsQuery(EventIdentifierType EventId) (GetAttendanceStatsQuery.cs:8), one positional parameter and no body. The XML doc (GetAttendanceStatsQuery.cs:3-7) fixes the contract: an event-scoped arrival count plus a per-session count for every session that has at least one session-scoped check-in.
  • Why it's built this way: the whole request is one event id, so the record is a one-line declaration; naming the message explicitly still lets the query pipeline decorate it uniformly and keeps the read intent greppable.
  • Where it's used: constructed by CheckInsController on GET /api/checkins/stats (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:191) from a [FromQuery, Required] EventIdentifierType eventId argument (CheckInsController.cs:187), on an endpoint gated by [HasPermission(EngagementPermissions.CheckInManage)] (CheckInsController.cs:183), and dispatched through the injected IQueryHandler<in TQuery, TResult> (CheckInsController.cs:43).

GetOrCreateMyBadgeCommand

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetOrCreateMyBadge · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeCommand.cs:11 · Level 0 · record (sealed)

  • What it is: the request for "give me my badge", a parameterless command. The attendee it applies to is never stated in the message: the handler reads the caller from the token.
  • Depends on: nothing. It has no members at all. Paired with GetOrCreateMyBadgeHandler, which answers with a MyBadgeDTO.
  • Concept introduced, the deliberately empty message as a security property. [Rubric §11, Security] assesses whether authorization decisions are made from trusted inputs rather than from client-supplied ones. The doc comment says exactly why the record is empty (GetOrCreateMyBadgeCommand.cs:5-9): the attendee is the caller, read from the token by the handler, and carrying a user id in the body would make "whose badge do I get" a client-supplied value, which is precisely the input an attendee could change to obtain someone else's credential. Removing the parameter removes the attack rather than validating it away. [Rubric §9, API & Contract Design] shows in the pairing with the route: the endpoint is GET /api/checkins/my-badge with no id segment and no body (CheckInsController, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:50), so the contract has nowhere to put an identity even if a caller wanted to.
  • Concept, a command that reads. It is declared a command, not a query, because the first call for an attendee writes a new badge row; see the rationale under GetOrCreateMyBadgeHandler.
  • Walkthrough: public sealed record class GetOrCreateMyBadgeCommand; (GetOrCreateMyBadgeCommand.cs:11). One line, no positional parameters, no body.
  • Why it's built this way: an empty message still buys the uniform dispatch, decoration, and naming of the CQRS pipeline while carrying no attack surface. The alternative (a UserId parameter validated against the token) would give the same behavior with a check that can be forgotten.
  • Where it's used: constructed by CheckInsController.GetMyBadgeAsync (CheckInsController.cs:57, method at :49) and dispatched through the injected ICommandHandler<in TCommand, TResult> (CheckInsController.cs:40).

RecordedCheckIn

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:38 · Level 0 · record struct (internal readonly)

  • What it is: the two-field outcome of a check-in write, nested inside CheckInProcessor: whether a check-in for the same target already existed, and the timestamp that applies (the original one when it did, the newly written one when it did not).
  • Depends on: nothing first-party. Only bool and DateTimeOffset from the BCL. It is produced by CheckInProcessor.RecordAsync and consumed by the three writers that call it.
  • Concept introduced, the small internal result type that keeps a shared helper honest. A RecordAsync that returned only a timestamp would force each caller to re-derive "was this a repeat", and one that returned a full DTO would force every caller into the same response shape even though the room path, the sponsor path, and the organizer path each answer with a different DTO. This record struct is the middle ground: it carries exactly the two facts the shared write half knows, and each caller maps them into its own contract. [Rubric §9, API & Contract Design] assesses whether a contract carries what it means and nothing more, which is what two named components buy here (AlreadyRecorded, RecordedOn) over a bool/DateTimeOffset tuple. [Rubric §12, Performance & Scalability] is the reason it is a readonly record struct rather than a class: the value is created on every check-in, has no identity, and is never mutated, so it stays on the stack. [Rubric §15, Best Practices & Code Quality]: internal keeps it a module-private detail, so no API-layer contract can accidentally start depending on it.
  • Walkthrough: one declaration, internal readonly record struct RecordedCheckIn(bool AlreadyRecorded, DateTimeOffset RecordedOn) (CheckInProcessor.cs:38), with the per-parameter docs directly above it (CheckInProcessor.cs:35-37): AlreadyRecorded is true when a prior check-in for the same target was found, and RecordedOn is that prior check-in's timestamp, or the timestamp of the one just written. Both construction sites are inside RecordAsync: the short-circuit path builds new RecordedCheckIn(AlreadyRecorded: true, existing.CheckedInOn) (CheckInProcessor.cs:74) and the write path builds new RecordedCheckIn(AlreadyRecorded: false, checkIn.CheckedInOn) (CheckInProcessor.cs:92). Both use named arguments, so the boolean at the call site says what it means.
  • Why it's built this way: idempotency is the whole point of the check-in write (a repeat scan must not double count), so the outcome type has to make "this was a repeat" a first-class fact rather than something a caller infers. Returning the original timestamp on a repeat is what lets a UI show the attendee when they actually checked in rather than when they rescanned.
  • Where it's used: returned as Result<RecordedCheckIn> from CheckInProcessor.RecordAsync (CheckInProcessor.cs:58). Three call sites unwrap it: CheckInProcessor's own ExecuteAsync maps it onto a CheckInResultDTO (CheckInProcessor.cs:143-149), RecordRoomCheckInHandler maps it onto a RoomCheckInResultDTO and suppresses its log line when AlreadyRecorded is true (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:87-97), and RecordSponsorVisitHandler does the same into a SponsorVisitResultDTO (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:85-95).

DependencyInjection

MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:15 · Level 1 · class (static, extension block)

  • What it is: the Engagement module's API-layer composition root. It exposes two extension methods on IServiceCollection: AddEngagementModule(...) chains the three layer registrations into one call, and AddModuleEngagementAPI() configures the shared ownership-authorization filter with ADC's vocabulary and declares the module's role-to-permission grants.
  • Depends on: ApplicationSettings (passed straight through), the Application and Infrastructure registration extensions (AddModuleEngagementApplication on the Application-layer DependencyInjection and AddModuleEngagementInfrastructure), the shared OwnerOrAdminFilterOptions / OwnerOrAdminFilter, the auth vocabulary RoleNames and EngagementPermissions (the latter fed into the IPermissionRegistry through AddPermissions), and System.Security.Claims.ClaimTypes from the BCL (DependencyInjection.cs:1).
  • Concept, C# extension(T) DI blocks composing a module top-down. The class body is an extension(IServiceCollection services) block (DependencyInjection.cs:17), the codebase-wide registration idiom (introduced in the primer): the members inside read as instance methods on any IServiceCollection. [Rubric §3, Clean Architecture] assesses whether layering is honored at composition time; AddEngagementModule registers Application, then Infrastructure, then API in dependency order (DependencyInjection.cs:26-28), so nothing binds before its dependencies exist. [Rubric §11, Security] assesses whether authorization is centralized and declarative: rather than hand-roll an ownership check, AddModuleEngagementAPI reuses MMCA.Common's OwnerOrAdminFilter and supplies only ADC's field names (ADR-033), then wires the module's permission grants through the shared registry (ADR-020) so the [HasPermission(...)]-gated endpoints resolve.
  • Walkthrough
    • AddEngagementModule(ApplicationSettings applicationSettings) (DependencyInjection.cs:24) calls AddModuleEngagementApplication(applicationSettings) (line 26), AddModuleEngagementInfrastructure() (line 27), then AddModuleEngagementAPI() (line 28), and returns services for chaining (line 30). This is the single method EngagementModule.Register invokes.
    • AddModuleEngagementAPI() (DependencyInjection.cs:43) does two things.
      • Configures OwnerOrAdminFilterOptions (DependencyInjection.cs:45-56) with OwnerClaimType = ClaimTypes.NameIdentifier (line 53), BypassRole = RoleNames.Organizer (line 54, the role that skips the ownership check), and OwnerParameterName = "userId" (line 55, the query argument the Bookmarks list endpoints bind). The comment above those three lines (DependencyInjection.cs:47-52) is the part worth reading twice: the token carries the user id in sub only, but the JWT bearer handler maps inbound sub onto ClaimTypes.NameIdentifier, so NameIdentifier is the claim type the principal actually carries by the time this filter runs; the raw sub form survives only where an identity is built straight from a token's claims (the Blazor client-side state provider), and those readers go through the claims-principal extensions instead of this filter. The filter itself is registered upstream by Common's AddAPI; this call injects only the module's vocabulary.
      • Calls AddPermissions(...) (DependencyInjection.cs:58) and grants both RoleNames.Organizer (line 60) and RoleNames.Admin (line 61) the full EngagementPermissions.All set through a collection spread. The doc comment (DependencyInjection.cs:33-41) records the complement: attendee-facing endpoints stay on a plain [Authorize] that carries no capability rather than on a permission gate. That is the split you see on CheckInsController, where the organizer stats endpoint carries [HasPermission(EngagementPermissions.CheckInManage)] (CheckInsController.cs:183) while the attendee's own-badge endpoint carries no permission attribute at all (CheckInsController.cs:50).
  • Why it's built this way: one AddEngagementModule entry point keeps the module's wiring in a single call the module loader can invoke; delegating ownership enforcement to a configured shared filter means that logic is written and tested once in Common (ADR-033); and declaring role grants in one place keeps the permission model auditable (ADR-020). The doc comment (DependencyInjection.cs:33-41) is the authoritative note on which ADC value maps to which option.
  • Where it's used: AddEngagementModule is called by EngagementModule.Register; AddModuleEngagementAPI runs inside it. The configured OwnerOrAdminFilter guards the list endpoints on BookmarksController; the granted permissions gate the [HasPermission(...)] endpoints on CheckInsController and the live-layer controllers covered in group-23.
  • Caveats / not-in-source: AddModuleEngagementInfrastructure lives in the Infrastructure project and is covered by that layer's section, not here.

EngagementModule

MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:14 · Level 5 · class (sealed)

  • What it is: the IModule implementation for the Engagement bounded context, the type ModuleLoader discovers by reflection and uses to slot Engagement into the application in dependency order.
  • Depends on: IModule (the contract), ApplicationSettings, the API-layer DependencyInjection extension (AddEngagementModule), and the two disabled-stub pairs it registers when Engagement is not co-hosted: IBookmarkCountService / DisabledBookmarkCountService and IUserEngagementExportService / DisabledUserEngagementExportService.
  • Concept, the module descriptor and its declared dependency on Conference. [Rubric §7, Microservices Readiness] assesses whether a modular monolith can be split into services without a rewrite; a module that declares its dependencies and can register disabled stubs for modules that are not co-hosted is exactly that boundary. Engagement names Conference as a dependency (EngagementModule.cs:20) and sets RequiresDependencies => true (EngagementModule.cs:23), so the loader orders Conference before Engagement in its topological (Kahn) sort. When Engagement runs as its own service, Conference is not in-process and the cross-module contracts are satisfied by gRPC clients instead (ADR-007 / ADR-008); conversely, in a host where Engagement is off, RegisterDisabledStubs supplies no-op implementations so a dependent module's calls into Engagement's bookmark count and data-subject export still resolve.
  • Walkthrough: five members implementing IModule.
    • Name => "Engagement" (EngagementModule.cs:17): the module's identity in the loader's graph.
    • Dependencies => ["Conference"] (EngagementModule.cs:20): the modules that must register first.
    • RequiresDependencies => true (EngagementModule.cs:23): declares the Conference dependency mandatory, not optional.
    • Register(...) (EngagementModule.cs:26-27): an expression body delegating the whole wiring to services.AddEngagementModule(applicationSettings) in the API DependencyInjection. Note the signature also receives an IConfigurationBuilder, which this module does not use.
    • RegisterDisabledStubs(...) (EngagementModule.cs:30-34): registers two singletons for hosts where Engagement is off, IBookmarkCountService as DisabledBookmarkCountService (line 32) and IUserEngagementExportService as DisabledUserEngagementExportService (line 33).
  • Why it's built this way: keeping the module's shape (name, dependencies, register, disabled stubs) behind one contract lets the loader treat every module uniformly, and lets a module run in-process or as an extracted service without changing its business code (ADR-007 / ADR-008). The doc comment (EngagementModule.cs:10-13) records that the module depends on Conference for session data.
  • Where it's used: instantiated and invoked by ModuleLoader during host startup; its Register routes into the API DependencyInjection.

GetOrCreateMyBadgeHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetOrCreateMyBadge · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:18 · Level 9 · class (sealed, partial)

  • What it is: the handler behind "show me my badge". It reads the caller's AttendeeBadge, mints one if this is the first call, and returns only the badge credential as a MyBadgeDTO.
  • Depends on: IUnitOfWork, ICurrentUserService, IUniqueConstraintViolationDetector, and ILogger<GetOrCreateMyBadgeHandler>, all injected through the primary constructor (GetOrCreateMyBadgeHandler.cs:18-22). It reads and writes the AttendeeBadge aggregate through an IRepository<TEntity, TIdentifierType> (narrowed to IEntityQuerier<TEntity, TIdentifierType> for the read helper), uses the framework's RequireUserId caller guard, and returns Result / Error. It implements ICommandHandler<in TCommand, TResult> closed over Result<MyBadgeDTO> (GetOrCreateMyBadgeHandler.cs:22).
  • Concept introduced, get-or-create as a command, and losing the insert race on purpose. Three design decisions carry this handler.
    • It is a command, not a query, and the class doc says why (GetOrCreateMyBadgeHandler.cs:12-17): the first call writes, because an attendee opening their badge for the first time gets a credential created on the spot, and every later call returns that same credential, so a badge printed or screenshotted yesterday still scans today. [Rubric §6, CQRS & Event-Driven] assesses whether the read/write split reflects actual effects rather than the verb in the method name; classifying a lazily-minting read as a command is that distinction taken seriously, and it puts the call on the transactional side of the decorator pipeline.
    • The read-then-insert is a race, and the race is handled rather than prevented. The unique index on AttendeeBadge.UserId (AttendeeBadgeConfiguration, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/AttendeeBadgeConfiguration.cs:31-32, with the comment at :29-30 naming this handler as the reason it exists) is the actual guarantee; the handler's pre-check is only an optimization. [Rubric §8, Data Architecture] assesses whether invariants live where they can be enforced, and one-badge-per-attendee lives in the database. [Rubric §29, Resilience & Business Continuity] shows in the recovery: the comment at the catch site (GetOrCreateMyBadgeHandler.cs:56-58) states that the promise is one stable credential per attendee, so the winner's badge is returned rather than a failure the caller would retry into.
    • Recognizing that collision is delegated, not hand-rolled. The injected IUniqueConstraintViolationDetector answers "was this save rejected for violating a unique constraint" without the Application layer naming a provider type; its own remarks (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUniqueConstraintViolationDetector.cs:9-30) record why the question is declared in Application and answered in Infrastructure, where SqlException.Number 2601 and 2627 are reachable, and note that a miss is safe rather than silent because an unclassified exception simply propagates. [Rubric §3, Clean Architecture] assesses whether the dependency rule holds at the awkward points, and this is one of them.
    • [Rubric §11, Security] runs through the whole method: the identity comes from currentUserService.RequireUserId(...) (GetOrCreateMyBadgeHandler.cs:29), never from the (empty) GetOrCreateMyBadgeCommand, and the response carries only the credential (line 74) because, as the comment says (lines 72-73), the DTO carries nothing that identifies the attendee, so the QR code built from it stays opaque to anyone who scans it.
  • Walkthrough
    • HandleAsync (GetOrCreateMyBadgeHandler.cs:25-27) starts with the caller guard currentUserService.RequireUserId("CheckIns.Forbidden") (line 28), the shared extension that collapses the read-then-null-check-then-fail block every per-user handler used to repeat (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:35-46); the module supplies only the error code, because the framework cannot know it. A failure returns its errors unchanged (GetOrCreateMyBadgeHandler.cs:30-33), which is an ErrorType.Forbidden carrying the generic "Access denied." message.
    • It unwraps the caller id (line 34), resolves the badge repository (line 35), and reads the caller's badge through the private ReadBadgeAsync helper (line 36).
    • If no badge exists (line 38), AttendeeBadge.Create(userId) runs (line 40); a failed factory result is returned as-is (GetOrCreateMyBadgeHandler.cs:42-43), otherwise the new badge is unwrapped (line 44).
    • The insert is wrapped in try (GetOrCreateMyBadgeHandler.cs:47-53): AddAsync (line 48), SaveChangesAsync (line 49), then the information-level LogBadgeIssued (line 51).
    • The catch (Exception exception) when (uniqueConstraintViolationDetector.IsUniqueConstraintViolation(exception)) filter (line 53) is the race path: it logs LogConcurrentDuplicate at debug (line 58), re-reads the badge (line 60), and only if the re-read still finds nothing returns Error.Conflict with code CheckIns.BadgeConflict, source nameof(GetOrCreateMyBadgeHandler), and target nameof(MyBadgeDTO.Credential) (GetOrCreateMyBadgeHandler.cs:64-68).
    • The single exit returns Result.Success(new MyBadgeDTO { Credential = badge.Credential }) (line 74), so a first-call mint and a repeat call answer with the identical shape.
    • ReadBadgeAsync (GetOrCreateMyBadgeHandler.cs:78-85) calls FirstOrDefaultAsync(b => b.UserId == userId, asTracking: false, ...) (lines 81-84): the read is untracked because it either returns an existing badge unchanged or is followed by a fresh insert. Its parameter is typed as the read-only IEntityQuerier<TEntity, TIdentifierType> (line 78), so the helper cannot write even though the caller passes it a full repository.
    • Two source-generated log methods close the file: LogBadgeIssued at information (GetOrCreateMyBadgeHandler.cs:87-88) and LogConcurrentDuplicate at debug (GetOrCreateMyBadgeHandler.cs:90-91). [Rubric §13, Observability & Operability]: the race is logged at debug because it is expected and benign, while a genuine issuance is worth an information-level record.
  • Why it's built this way: minting on demand means no badge table has to be pre-populated for every registered user, and returning the same credential forever means a printed or screenshotted badge keeps working. Treating the unique-constraint failure as "someone else already created what I wanted" turns the only concurrency hazard into a re-read, so the endpoint stays idempotent from the caller's point of view.
  • Where it's used: dispatched from CheckInsController.GetMyBadgeAsync on GET /api/checkins/my-badge (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:50-57); registered by the convention scan in the Application-layer DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88).

CheckInProcessor

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:23 · Level 11 · class (internal static)

  • What it is: the check-in core every check-in entry point runs: validate the target, short-circuit a repeat check-in, otherwise record one. It is split into a write half (RecordAsync, shared by all four writers) and an organizer-path half (ExecuteAsync, which resolves the target through Conference first), plus one shared error factory.
  • Depends on: IUnitOfWork, Conference's IEventLiveValidationService, the CheckIn aggregate and its CheckInScope discriminator, CheckInResultDTO, the read surface IEntityQuerier<TEntity, TIdentifierType> obtained through GetRepository, and Result / Error. Externals: System.Linq.Expressions (CheckInProcessor.cs:1) and TimeProvider.
  • Concept introduced, the static collaborator-passing helper. [Rubric §1, SOLID], [Rubric §14, Testability]. The class doc (CheckInProcessor.cs:10-22) explains the shape: it is a static helper rather than an injected service so no handler gains a dependency it does not own. Each caller passes the collaborators it already holds (IUnitOfWork, the validation service, a TimeProvider), which keeps the rule in one place without adding an interface, a registration, and a mock to every test. The cost is that it cannot be substituted in a unit test: a test of CheckInAttendeeHandler exercises this code too, so the handler tests are where its branches are covered.
  • Concept, idempotency as an application rule with a database backstop. [Rubric §6, CQRS & Event-Driven], [Rubric §29, Resilience & Business Continuity]. The RecordAsync remarks (CheckInProcessor.cs:43-48) state the full model: a repeat scan reports the original check-in instead of writing a second row or publishing a second integration event; the filtered unique indexes from CheckInConfiguration are the race backstop for two concurrent scans of the same badge or booth (the losing save fails rather than duplicating), and the broker retry that follows lands on this same no-op path. Those indexes are one per scope: (UserId, EventId) (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:49-51), (UserId, SessionId) (:54-56), and (UserId, SponsorId) (:60-62), each IsUnique() with a soft-delete filter plus a scope filter, which is exactly the three-way split FindExistingAsync mirrors in LINQ.
  • Concept, the caller's event id is not trusted for a session check-in. [Rubric §8, Data Architecture]. ExecuteAsync takes the resolved event from the validation result, not from the request (CheckInProcessor.cs:128), and the comment above it (CheckInProcessor.cs:124-127) gives the failure mode it prevents: a stale event context on the organizer's screen would file the attendance under the wrong event, where the rollup GetAttendanceStatsHandler runs would never find it again.
  • Walkthrough
    • EventNotPublished(string source) (CheckInProcessor.cs:29-33): the shared "the owning event is not published" failure, Error.Validation with code CheckIns.EventNotPublished and target nameof(CheckIn.EventId). source is a parameter (documented at :26-28) so the error still names the entry point the caller used, which is why the two attendee-path handlers can raise the identical error from their own code.
    • RecordedCheckIn (CheckInProcessor.cs:38): the nested outcome type, covered in its own section above.
    • RecordAsync(...) (CheckInProcessor.cs:58-93): nine parameters, no state. It resolves the writable repository (line 69), looks for an existing check-in for the same target (line 71) and returns AlreadyRecorded: true with the original timestamp when it finds one (CheckInProcessor.cs:72-75); otherwise it calls CheckIn.Create(...) with timeProvider.GetUtcNow() (CheckInProcessor.cs:77-84), returns the factory's errors on failure (lines 85-86), adds and saves (lines 89-90), and returns AlreadyRecorded: false (line 92). Note it takes both sessionId and sponsorId as nullable parameters, so all three scopes go through this one write.
    • ExecuteAsync(...) (CheckInProcessor.cs:109-150): the organizer path. It validates the target first (lines 120-122), takes resolvedEventId from the validation result (line 128), delegates the write to RecordAsync with sponsorId: null (lines 130-139), propagates a write failure (lines 140-141), and maps the outcome onto a CheckInResultDTO carrying UserId, AlreadyCheckedIn, and CheckedInOn (CheckInProcessor.cs:143-149).
    • ValidateTargetAsync(...) (CheckInProcessor.cs:161-188): for a session scope it calls GetSessionLiveInfoAsync and returns the session's own EventId (lines 168-178); otherwise GetEventLiveInfoAsync and returns the requested event id (lines 180-187). Both paths run the published check. The doc comment (CheckInProcessor.cs:152-160) records a deliberate v1 omission: the live window is not enforced, because an organizer scanning badges at the door or in the room works ahead of and behind the published clock, so a window check would reject legitimate check-ins; only the published and eligible facts gate the write.
    • EnsureEventIsPublished(bool isPublished) (CheckInProcessor.cs:190-193): success, or EventNotPublished(nameof(CheckInProcessor)).
    • FindExistingAsync(...) (CheckInProcessor.cs:201-217): a switch expression picks the scope-specific predicate (lines 210-215) and hands it to FirstOrDefaultAsync with asTracking: false (line 216), correct here because the existing row is only inspected, never mutated. The three predicate builders are SameSession (line 219), SameSponsor (line 222), and SameEvent (line 225); each pins c.Scope as well as the target id, so a read can never cross scopes. The doc (CheckInProcessor.cs:195-200) ties them back to the filtered unique indexes they mirror.
  • Why it's built this way: pulling the flow out of the handlers means the organizer scan path, the manual fallback, the room QR path, and the sponsor booth path cannot drift apart in their idempotency, timestamp, or event-resolution behavior, which for an attendance record is the difference between a defensible number and a guess. [Rubric §15, Best Practices & Code Quality]. Splitting RecordAsync out of ExecuteAsync is what lets the two attendee-driven paths share the write while doing their own target lookup, since each asks Conference a different question (CheckInProcessor.cs:15-21).
  • Where it's used: ExecuteAsync by CheckInAttendeeHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeHandler.cs:60) and ManualCheckInHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/ManualCheckIn/ManualCheckInHandler.cs:40). RecordAsync and EventNotPublished directly by RecordRoomCheckInHandler (RecordRoomCheckInHandler.cs:74 and :67) and RecordSponsorVisitHandler (RecordSponsorVisitHandler.cs:72 and :64), which resolve their own target first and then share this write half.
  • Caveats / not-in-source: ExecuteAsync takes no sponsorId and ValidateTargetAsync has only a session branch and an event branch, so a sponsor visit cannot be recorded through the organizer path even though CheckInScope admits it; the sponsor scope reaches RecordAsync only through RecordSponsorVisitHandler, whose own comment cites the once-per-sponsor filtered index as its race backstop (RecordSponsorVisitHandler.cs:68-70). The sessionId!.Value dereference at CheckInProcessor.cs:170 is guarded only by the caller passing a session id together with a session scope, not by anything in this file.

GetAttendanceStatsHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetAttendanceStats · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetAttendanceStats/GetAttendanceStatsHandler.cs:16 · Level 11 · class (sealed)

  • What it is: the query handler for GetAttendanceStatsQuery. It answers the organizer dashboard with one event-scoped arrival count plus one row per session that has check-ins, as an AttendanceStatsDTO.
  • Depends on: IUnitOfWork through its primary constructor (GetAttendanceStatsHandler.cs:16). It reads the CheckIn aggregate through an IReadRepository<TEntity, TIdentifierType>, filters on CheckInScope, and builds SessionAttendanceDTO rows inside an AttendanceStatsDTO. It implements IQueryHandler<in TQuery, TResult> (GetAttendanceStatsHandler.cs:17).
  • Concept introduced, the persistence-neutral grouped count. [Rubric §12, Performance & Scalability] assesses whether a read pulls only what it needs. The class doc (GetAttendanceStatsHandler.cs:10-15) is explicit: both figures are read through the focused repository surface and both are computed in SQL, a COUNT for the event scope and a grouped COUNT per session, so what crosses the wire is one row per session rather than one per check-in. The member that makes this possible is CountByAsync, declared on the shared querier contract (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:167-172); its remarks (IRepository.cs:156-161) name the problem it removes, that the Application layer references no EF Core and therefore has no IQueryable to group, which would otherwise force a handler to project every matching row out of the database and fold it in memory. [Rubric §3, Clean Architecture] is the reason the fix took that shape: the aggregate is expressed as a persistence-neutral member rather than by letting the handler see the provider. [Rubric §8, Data Architecture] shows in how the two scopes are distinguished: a single CheckIn table holds both event arrivals and session attendance, separated by the Scope discriminator rather than by two tables, and CheckInConfiguration indexes EventId and SessionId for exactly this read (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:64-69). [Rubric §6, CQRS & Event-Driven]: this is a pure read, resolved through GetReadRepository (line 25) rather than the writable repository, so nothing enters the change tracker.
  • Walkthrough
    • HandleAsync (GetAttendanceStatsHandler.cs:20-22) null-guards the query with ArgumentNullException.ThrowIfNull(query) (line 23).
    • It resolves the read repository for CheckIn (line 25).
    • The event figure is a server-side count: CountAsync(c => c.EventId == query.EventId && c.Scope == CheckInScope.Event, cancellationToken) (GetAttendanceStatsHandler.cs:28-30), which translates to a SQL COUNT and never materializes a row.
    • The session figures are a server-side GROUP BY: CountByAsync(c => c.SessionId!.Value, c => c.EventId == query.EventId && c.Scope == CheckInScope.Session && c.SessionId != null, cancellationToken) (GetAttendanceStatsHandler.cs:32-35) returns an IReadOnlyDictionary<SessionIdentifierType, int> holding one entry per session that has at least one session-scoped check-in of this event. The != null filter is what makes the SessionId!.Value key selector safe.
    • The rollup is built with a collection expression (GetAttendanceStatsHandler.cs:37-42): OrderBy(pair => pair.Key) (line 39) makes the output order deterministic (a dictionary has none), and each pair becomes a SessionAttendanceDTO { SessionId = pair.Key, Count = pair.Value } (line 40).
    • It returns Result.Success(new AttendanceStatsDTO { EventId = ..., EventAttendance = ..., SessionAttendance = ... }) (GetAttendanceStatsHandler.cs:44-49). There is no failure branch: with a valid event id the query always succeeds, and an event with no check-ins simply yields zero and an empty list.
  • Why it's built this way: pushing both aggregates into SQL keeps the response size proportional to the number of sessions rather than to the number of attendees, which is the difference that matters on a conference day; sorting by key in memory afterwards costs nothing and means two calls with the same data return rows in the same order, which matters for a dashboard and for snapshot-style tests.
  • Where it's used: dispatched from CheckInsController.GetAttendanceStatsAsync on GET /api/checkins/stats?eventId= (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:182-194), an endpoint gated by [HasPermission(EngagementPermissions.CheckInManage)] (CheckInsController.cs:183); registered by the convention scan in the Application-layer DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88).

DependencyInjection

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:29 · Level 12 · class (static, extension block)

  • What it is: the Engagement module's Application-layer registration. Its single extension method AddModuleEngagementApplication(...) registers the module's domain service, its per-aggregate services (bookmarks, the two live-layer aggregates, and the points awarder), and the live-channel publish queue explicitly, then convention-scans the assembly for everything uniform (handlers, mappers, validators, event handlers).
  • Depends on: ApplicationSettings (accepted but not yet consumed), the Engagement services it registers (IBookmarkManagementDomainService / BookmarkManagementDomainService, IBookmarkCountService / BookmarkCountService, IUserEngagementExportService / UserEngagementExportService, ILiveChannelPublishQueue / LiveChannelPublishQueue, IPointsAwarder / PointsAwarder), the shared generic services (INavigationPopulator<in TEntity> / NullNavigationPopulator<TEntity>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> / EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, the DeleteEntityCommand<TEntity, TIdentifierType> / DeleteEntityHandler<TEntity, TIdentifierType> pairing bound through ICommandHandler<in TCommand, TResult>), the live-layer types it also wires (LivePoll, LivePollVote, LivePollOption, LivePollNavigationPopulator, LivePollOptionNavigationPopulator, LivePollResultsBuilder, DeleteLivePollHandler, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder), and the ClassReference marker.
  • Concept, explicit registration plus convention scanning, with idempotent TryAdd. [Rubric §2, Design Patterns] assesses recognized composition idioms: this method mixes hand-written registrations (for services that need a specific lifetime or a closed generic the scanner cannot infer) with one convention scan for the many uniform types. [Rubric §33, Developer Experience] shows in ScanModuleApplicationServices<ClassReference>() (DependencyInjection.cs:88): a new use case is picked up as soon as its handler exists, with no registration edit, which is why neither GetOrCreateMyBadgeHandler nor GetAttendanceStatsHandler appears by name here. Every explicit line uses TryAdd* rather than Add*, so a registration is a no-op when the same service was already contributed elsewhere, which keeps module wiring composable across hosts. [Rubric §15, Best Practices & Code Quality]: the comments in this file carry the reasoning that the call sites cannot, notably why the queue is registered twice and why two aggregates deliberately get no populator.
  • Walkthrough
    • AddModuleEngagementApplication(ApplicationSettings applicationSettings) (DependencyInjection.cs:33, inside the extension(IServiceCollection services) block at line 30) opens with _ = applicationSettings; and the comment "Reserved for future use" (line 34), acknowledging the parameter is part of the uniform module signature but unused today.
    • Domain service: TryAddSingleton<IBookmarkManagementDomainService, BookmarkManagementDomainService>() (line 37).
    • Per-aggregate services for UserSessionBookmark: a NullNavigationPopulator (line 40, the bookmark aggregate needs no navigation loading), an EntityQueryService over UserSessionBookmarkDTO for its read surface (line 41), and a DeleteEntityHandler bound to ICommandHandler<DeleteEntityCommand<UserSessionBookmark, ...>, Result> (line 42), so delete reuses the framework's generic handler.
    • Cross-module services: TryAddScoped<IBookmarkCountService, BookmarkCountService>() (line 45), the count Conference consumes, and TryAddScoped<IUserEngagementExportService, UserEngagementExportService>() (line 49), the Engagement half of the data-subject export that Identity aggregates across services (the comment at lines 47-48 cites PRIVACY.md §7, [Rubric §30, Compliance/Privacy/Data Governance]).
    • Live-channel publish queue (DependencyInjection.cs:56-57): the concrete LiveChannelPublishQueue is registered as a singleton (line 55) and ILiveChannelPublishQueue is registered as a factory resolving that same instance (line 56). The double registration is deliberate and the comment above it (lines 51-54) explains why: handlers enqueue post-commit broadcasts instead of awaiting the gRPC publish inline, and the Infrastructure-hosted drain needs the concrete type's reader while handlers only need the interface, so both must share one instance.
    • Live-layer LivePoll aggregate (DependencyInjection.cs:60-69): a real LivePollNavigationPopulator (line 59, unlike the bookmark aggregate this one has navigations to load), a NullNavigationPopulator for LivePollVote (line 60), a LivePollOptionNavigationPopulator for LivePollOption (line 64, whose comment at lines 62-63 notes options are normally hydrated through the poll's own child descriptor and that this covers the option's back-reference on the manual path), an EntityQueryService over LivePollDTO (line 66), the module's own DeleteLivePollHandler bound to the generic delete command rather than the framework handler (line 67), and the LivePollResultsBuilder projection helper (line 68).
    • Live-layer SessionQuestion aggregate (DependencyInjection.cs:72-74): NullNavigationPopulators for SessionQuestion (line 71) and SessionQuestionUpvote (line 72), plus the SessionQuestionViewBuilder (line 73).
    • Points (gamification): TryAddScoped<IPointsAwarder, PointsAwarder>() (line 83). The comment above it (lines 75-82) records two things worth reading: there is one write path into the ledger, so the check-in and feedback integration-event handlers and the session-question domain-event handler all resolve this from their own scope and hand it an activity plus subject-key pair; and it is registered by hand because it is a plain service rather than a handler, so the convention scan below does not see it. The same comment states that PointsEntry and LeaderboardOptIn deliberately get NO INavigationPopulator, since neither has a navigation to populate, exactly like the CheckIn and AttendeeBadge aggregates this module already queries through the repository without one.
    • services.ScanModuleApplicationServices<ClassReference>() (line 87): convention-scans this assembly for domain-event handlers, DTO and request mappers, command and query handlers, and validators (comment at DependencyInjection.cs:86-87), then the method returns services for chaining (line 89).
  • Why it's built this way: explicit TryAdd registrations pin the lifetimes and closed generics the scanner cannot infer, while the single scan removes the per-handler registration ceremony; together they keep module wiring short and additive. Passing the assembly marker ClassReference rather than a business type keeps the scan decoupled from any specific handler. The live-layer aggregates are registered here even though their behavior is taught in group-23, because a module registers all of its aggregates from one Application-layer entry point.
  • Where it's used: called by AddEngagementModule in the API DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:26), which is in turn invoked by EngagementModule.Register during host startup.

GetBookmarkedSessionIdsQuery

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.GetBookmarkedSessionIds · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/GetBookmarkedSessionIds/GetBookmarkedSessionIdsQuery.cs:5 · Level 0 · record (sealed)

  • What it is: the read request for "which sessions has this user bookmarked", a single-parameter query carrying only the UserId. Its handler answers with a map from session id to bookmark id, so a listing page can render per-row bookmark state.
  • Depends on: the UserIdentifierType alias (an identifier type alias linked solution-wide, see primer §2); paired with GetBookmarkedSessionIdsHandler through the CQRS query contract.
  • Concept, the CQRS query as an immutable record. This is a vertical-slice read: the query type and its handler live in the same UseCases/GetBookmarkedSessionIds/ folder. [Rubric §6, CQRS & Event-Driven] assesses whether reads and writes are modeled as distinct messages; this is a pure read, so it carries no validator and no transaction (the query pipeline skips both, see the decorator order in IQueryHandler<in TQuery, TResult>). [Rubric §5, Vertical Slice] assesses feature-folder cohesion, which this layout embodies. The record shape gives value equality and immutability for free, so the query is a safe, comparable message.
  • Walkthrough: public sealed record GetBookmarkedSessionIdsQuery(UserIdentifierType UserId) (GetBookmarkedSessionIdsQuery.cs:5), one positional parameter and no body. The XML doc (GetBookmarkedSessionIdsQuery.cs:3-4) states the intent: the set of session ids the user has bookmarked, keyed by session id to bookmark id.
  • Why it's built this way: a dedicated query type keeps the read intent explicit and lets the IQueryHandler<in TQuery, TResult> pipeline decorate it uniformly; carrying just the UserId keeps the request minimal.
  • Where it's used: constructed by BookmarksController's GET /api/bookmarks/session-ids action from the userId query argument (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:113) and dispatched to GetBookmarkedSessionIdsHandler; the resulting map drives the star indicator per row on the unified Sessions page. The record's property round-trip is pinned by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/UserSessionBookmarks/UseCases/GetBookmarkedSessionIdsHandlerTests.cs:39.

GetLeaderboardQuery

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.GetLeaderboard · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardQuery.cs:12 · Level 0 · record (sealed)

  • What it is: the read request for the public points leaderboard. It is a parameterless record: the board has no page, no filter, and no caller-chosen size.
  • Depends on: nothing. It is paired with GetLeaderboardHandler through the CQRS query contract, and the board length it implies comes from PointsSettings, not from the message.
  • Concept, the empty message as a security decision. A query with no parameters looks like an oversight until you read why it has none: the doc comment (GetLeaderboardQuery.cs:5-10) records that the board is a single fixed-size list whose length comes from configuration (Points:LeaderboardSize), so "there is no parameter through which a caller could ask for more of the board than the organizer chose to publish". [Rubric §11, Security] assesses whether inputs that widen a disclosure exist at all; the cheapest way to make a knob untamperable is not to expose it. [Rubric §9, API & Contract Design] assesses whether a contract says what it means: an empty record is an honest statement that this read has exactly one shape. [Rubric §30, Compliance/Privacy/Data Governance] is in scope because what the board publishes is attendee display names, which is why the size question is a privacy question and not just a paging one.
  • Walkthrough: public sealed record class GetLeaderboardQuery; (GetLeaderboardQuery.cs:12), a declaration with no positional parameters and no body. Callers write new GetLeaderboardQuery().
  • Why it's built this way: keeping the size in configuration rather than in the message means an operator can shorten or lengthen the published board without a deploy and without a client being able to override it; keeping the type (rather than passing no message at all) keeps the read on the same IQueryHandler<in TQuery, TResult> pipeline as every other query.
  • Where it's used: constructed by PointsController's GET /api/points/leaderboard action (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:82), whose own doc restates the fixed-length rule (PointsController.cs:66-74), and handled by GetLeaderboardHandler. The controller test asserts the action takes no parameter other than the cancellation token (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/PointsControllerTests.cs:114).

GetMyPointsQuery

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.GetMyPoints · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetMyPoints/GetMyPointsQuery.cs:14 · Level 0 · record (sealed)

  • What it is: the read request for the calling attendee's own points: their running total, their leaderboard status, and one page of the ledger entries behind the total. It carries paging arguments and nothing else.
  • Depends on: nothing first-party. Paired with GetMyPointsHandler, which produces a MyPointsDTO.
  • Concept, the deliberately absent user id. The most instructive thing about this record is a parameter it does not have. The doc comment (GetMyPointsQuery.cs:6-10) states it plainly: the attendee is the caller, read from the token by the handler, because "a user id in the query would make 'whose points do I get' a client-supplied value, which is exactly the input an attendee could change to read someone else's ledger". [Rubric §11, Security] assesses whether authorization decisions depend on trusted inputs: an identity that is never accepted from the client cannot be forged by the client, which is a stronger guarantee than any check placed after it. Contrast GetUserBookmarksQuery, which does carry a UserId and therefore needs the OwnerOrAdminFilter at the API edge to compare it against the caller's claim; this query removes the need for that comparison by removing the parameter.
  • Walkthrough: public sealed record class GetMyPointsQuery(int PageNumber = 1, int PageSize = 20) (GetMyPointsQuery.cs:14). Two positional parameters, both defaulted, so new GetMyPointsQuery() is the common call. The per-parameter docs (GetMyPointsQuery.cs:12-13) record where the safety lives: page numbers below 1 are treated as page 1, and the page size is "clamped into 1 to 100 by the handler", not by the record.
  • Why it's built this way: defaults keep the ordinary call trivial, and pushing the clamping into the handler means the ceiling holds for every caller including tests and any future in-process dispatcher, not only for requests that passed through the controller's [Range] attributes (PointsController.cs:54-55).
  • Where it's used: constructed by PointsController's GET /api/points/me action from the pageNumber/pageSize query arguments (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:59); the controller test asserts the arguments arrive on the query unchanged and that no user id is passed (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/PointsControllerTests.cs:43), and a separate reflection test asserts that no action on the controller takes a user id from the caller at all (PointsControllerTests.cs:316). Handled by GetMyPointsHandler.

GetPointsOverviewQuery

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.GetPointsOverview · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetPointsOverview/GetPointsOverviewQuery.cs:8 · Level 0 · record (sealed)

  • What it is: the read request for the organizer rollup of the points game: participation, payout, the per-activity breakdown, and a tail of recent activity. Its one parameter says how long that tail should be.
  • Depends on: nothing first-party. Paired with GetPointsOverviewHandler, which produces a PointsOverviewDTO.
  • Concept: the same single-purpose query record introduced under GetBookmarkedSessionIdsQuery, here with one bounded knob. [Rubric §12, Performance & Scalability] assesses whether a caller can ask for an unbounded amount of data: RecentCount looks unbounded on the record, and the doc comment (GetPointsOverviewQuery.cs:7) points at where it is not, "clamped into 1 to 100 by the handler". Note what the query cannot ask for: there is no attendee filter, because the rollup is deliberately identity-free (see GetPointsOverviewHandler).
  • Walkthrough: public sealed record class GetPointsOverviewQuery(int RecentCount = 20) (GetPointsOverviewQuery.cs:8), one defaulted positional parameter. The summary (GetPointsOverviewQuery.cs:3-6) enumerates the four figures the rollup returns.
  • Why it's built this way: one query type per organizer screen keeps the read explicit, and defaulting RecentCount means the dashboard's ordinary call carries no arguments at all.
  • Where it's used: constructed by PointsController's GET /api/points/overview action (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:130), which is the one Points endpoint behind [HasPermission(EngagementPermissions.PointsViewOverview)] (PointsController.cs:121, ADR-020); the controller tests pin the pass-through of recentCount (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/PointsControllerTests.cs:209) and the permission gate (PointsControllerTests.cs:293).

GetUserBookmarksQuery

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.GetUserBookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/GetUserBookmarks/GetUserBookmarksQuery.cs:8 · Level 0 · record (sealed)

  • What it is: the read request for a user's bookmarked sessions as a paged list, with an optional event filter. Unlike GetBookmarkedSessionIdsQuery, which returns a lightweight id map, this query yields full bookmark DTOs plus pagination metadata.
  • Depends on: the UserIdentifierType and EventIdentifierType aliases (see primer §2); paired with GetUserBookmarksHandler.
  • Concept, a paged query with defaulted parameters. [Rubric §9, API & Contract Design] assesses whether list endpoints expose paging and filtering as first-class inputs; this query does, defaulting PageNumber to 1 and PageSize to 10 so a caller can omit both. [Rubric §12, Performance & Scalability] assesses whether list reads are bounded: the doc comment (GetUserBookmarksQuery.cs:7) records the 500-item ceiling from BR-11, and the handler, not the record, enforces it. The optional EventIdentifierType? filter (BR-58, GetUserBookmarksQuery.cs:5) is resolved through a Session lookup that lives in another module, which is why the handler reaches into Conference.
  • Walkthrough: public sealed record GetUserBookmarksQuery(UserIdentifierType UserId, EventIdentifierType? EventId, int PageNumber = 1, int PageSize = 10) (GetUserBookmarksQuery.cs:8-12). Four positional parameters: the required UserId, the nullable EventId filter, and the two paging knobs with defaults. The XML doc (GetUserBookmarksQuery.cs:3-7) ties each parameter to its business rule.
  • Why it's built this way: defaulted paging parameters keep the common call simple while still allowing explicit paging; making EventId nullable models "all events" as the absence of a filter rather than as a magic value. The UserId is a real parameter here (unlike on GetMyPointsQuery) because an Organizer is allowed to read another attendee's bookmark list, which is exactly why the endpoint needs an ownership filter at the edge.
  • Where it's used: constructed by BookmarksController's GET /api/bookmarks action (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:95) behind [ServiceFilter(typeof(OwnerOrAdminFilter))] (BookmarksController.cs:85, ADR-033); handled by GetUserBookmarksHandler, which returns a PagedCollectionResult<T> of UserSessionBookmarkDTO.

LiveChannelPublishWorkItem

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Live · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:10 · Level 0 · record (sealed)

  • What it is: one pending best-effort live-channel broadcast, captured as an immutable three-field record. It is exactly the argument tuple a drain worker will later hand to ILiveChannelPublisher.PublishAsync.
  • Depends on: nothing but string. It is deliberately primitive-only so the item can sit in a queue with no entity, no DbContext, and no scope attached. Consumed by ILiveChannelPublishQueue and, on the other side, ILiveChannelPublisher.
  • Concept, the deferred-work message. [Rubric §12, Performance & Scalability] assesses whether slow out-of-process work is kept off the request path: representing a broadcast as data (rather than as an in-flight await) is what makes deferral possible at all. [Rubric §29, Resilience & Business Continuity] assesses failure containment: because the payload is pre-serialized into PayloadJson before enqueueing, the drain never needs the originating scope, its entities, or its DbContext, so a slow publish cannot pin request-scoped resources. The doc comment (ILiveChannelPublishQueue.cs:3-9) ties the type to BR-229 and ADR-039.
  • Walkthrough: public sealed record LiveChannelPublishWorkItem(string ChannelKey, string EventName, string PayloadJson) (ILiveChannelPublishQueue.cs:10). ChannelKey is the session or event channel key (the doc points at LivePollChannel, ILiveChannelPublishQueue.cs:7), EventName is the channel event name such as poll.results-changed (ILiveChannelPublishQueue.cs:8), and PayloadJson is the pre-serialized broadcast body, which the doc states never carries per-user data (ILiveChannelPublishQueue.cs:9).
  • Why it's built this way: a broadcast fans out to every subscriber of a channel, so the payload must be user-agnostic; encoding that rule in the doc of a single-purpose record keeps it visible at every construction site. Serializing eagerly also means the queue holds a small, immutable string triple rather than a live object graph.
  • Where it's used: constructed by the live-layer command handlers after their commit, notably OpenLivePollHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:110), CloseLivePollHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Close/CloseLivePollHandler.cs:95), SubmitQuestionHandler (SubmitQuestionHandler.cs:142, :157), and ModerateQuestionHandler (ModerateQuestionHandler.cs:140, :154), plus the two post-commit domain-event handlers LivePollVoteChangedHandler (LivePollVoteChangedHandler.cs:79) and SessionQuestionUpvoteChangedHandler (SessionQuestionUpvoteChangedHandler.cs:80); drained by LiveChannelPublishProcessor.

OptInRow

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.GetLeaderboard · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:85 · Level 0 · record (private sealed, nested)

  • What it is: the projection shape for one opted-in attendee inside GetLeaderboardHandler: the attendee's id and the display name they published when they joined the board.
  • Depends on: the UserIdentifierType alias; string (BCL). Nothing else, which is the point.
  • Concept, the private projection record. A projection read needs a target type, and the choice of target is a design decision. Using an anonymous type (as GetBookmarkedSessionIdsHandler does) is fine when the shape is consumed one line later; naming the shape pays off when it is threaded through several LINQ stages, because the name documents what the two columns mean. Declaring it private and nested keeps it invisible outside the handler, so it can never be mistaken for a contract: nothing about it is a promise to any caller. [Rubric §12, Performance & Scalability] assesses reading only the columns you need, which is what a projection record makes explicit at the call site. [Rubric §15, Best Practices & Code Quality]: the type sits inside the only file entitled to change it.
  • Walkthrough: private sealed record class OptInRow(UserIdentifierType UserId, string DisplayName) (GetLeaderboardHandler.cs:85), two positional members, with per-parameter docs at :83-84 naming them as the attendee who joined the board and the name they published at opt-in.
  • Why it's built this way: record gives value semantics for free and sealed plus private keeps the shape local. The DisplayName carried here is the snapshot taken at opt-in, which is why the board can be rendered with no call into Identity at all.
  • Where it's used: the select argument of the LeaderboardOptIn projection read (GetLeaderboardHandler.cs:41), then read again for the user-id list that scopes the ledger sum (:48), for the ordering (:68-69), and for the final DTO projection (:71-76). It is the only projection row in this handler: the points side is summed by the database rather than projected, see GetLeaderboardHandler.

ILiveChannelPublishQueue

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Live · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:21 · Level 1 · interface

  • What it is: the one-method boundary between a command handler and the live-channel broadcast path. Handlers hand a LiveChannelPublishWorkItem to this queue instead of awaiting a gRPC publish inline.
  • Depends on: LiveChannelPublishWorkItem only. Its implementation is LiveChannelPublishQueue and its drain is LiveChannelPublishProcessor, which forwards to ILiveChannelPublisher.
  • Concept introduced, the non-blocking hand-off that decouples a hot path from a remote peer. The failure mode this exists to prevent is stated in the doc comment (ILiveChannelPublishQueue.cs:12-20): a hung (not refused) Notification peer would otherwise stall a user request for as long as the publish takes, because an inline await inherits the peer's latency. Enqueueing instead makes the handler's cost bounded and synchronous. [Rubric §29, Resilience & Business Continuity] assesses how a dependency's degradation propagates: here it cannot propagate at all, since a failed publish (including a full queue) is logged by the caller or drain and never fails the command (ILiveChannelPublishQueue.cs:18-19). [Rubric §1, SOLID] (ISP and DIP): the interface exposes exactly one method, and the Application layer depends on this abstraction rather than on the transport, so the concrete channel and the hosted drain both live outside the use-case code. [Rubric §13, Observability & Operability] shows in how the contract keeps a discard observable without burdening the caller: it hands callers nothing to branch on and points at the implementation's dropped-count warning instead (ILiveChannelPublishQueue.cs:24-26).
  • Walkthrough: a single member. void Enqueue(LiveChannelPublishWorkItem workItem) (ILiveChannelPublishQueue.cs:30) performs a non-blocking enqueue. Its doc (ILiveChannelPublishQueue.cs:23-27) pins the contract precisely: the queue never rejects an item, because under backpressure it discards the OLDEST pending broadcast to make room, so there is nothing for a caller to branch on, and discards surface through the implementation's dropped-count warning rather than through a return value. A null work item throws ArgumentNullException (ILiveChannelPublishQueue.cs:29). There is no async variant, so the call cannot suspend the handler.
  • Why it's built this way: ADR-039 makes live-channel push best-effort, so the publish must be a side channel rather than a step of the command. The doc comment also records the ordering guarantee the design buys: a single-reader drain forwards items in FIFO order, which preserves per-session event ordering (ILiveChannelPublishQueue.cs:15-17). Keeping the interface in the Application layer, with the drain in Infrastructure, keeps the transport dependency pointing inward only (Clean Architecture, [Rubric §3]).
  • Where it's used: constructor-injected into all four live-layer command handlers, OpenLivePollHandler (OpenLivePollHandler.cs:23), CloseLivePollHandler (CloseLivePollHandler.cs:22), SubmitQuestionHandler (SubmitQuestionHandler.cs:29), and ModerateQuestionHandler (ModerateQuestionHandler.cs:26), plus the two post-commit domain-event handlers (LivePollVoteChangedHandler.cs:40, SessionQuestionUpvoteChangedHandler.cs:41); registered against the singleton implementation in the Application-layer DependencyInjection (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:57).

OverviewRow

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.GetPointsOverview · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetPointsOverview/GetPointsOverviewHandler.cs:102 · Level 1 · record (private sealed, nested)

  • What it is: the projection shape GetPointsOverviewHandler reads the ledger into: one row per points entry, carrying the six fields every figure in the organizer rollup is computed from.
  • Depends on: the UserIdentifierType and PointsEntryIdentifierType aliases, and PointsActivityType (which is why it sits at Level 1 rather than 0); DateTime and string (BCL).
  • Concept, the internal shape that carries more than the output does. This row deliberately holds a field that never leaves the handler. Its doc says so (GetPointsOverviewHandler.cs:92-95): the attendee id "is present here so the distinct participant count can be computed, and is dropped before anything leaves the handler". [Rubric §30, Compliance/Privacy/Data Governance] assesses data minimization at the boundary rather than in the query: you cannot count distinct participants without reading who they are, so the id is read, aggregated, and then discarded when the rows are projected into the identity-free PointsEntryDTO. Keeping the wider shape private is what makes that discard structural instead of a convention someone must remember. [Rubric §3, Clean Architecture]: the internal read model is not the published contract, and here they are two different types on purpose.
  • Walkthrough: private sealed record class OverviewRow(UserIdentifierType UserId, PointsEntryIdentifierType Id, PointsActivityType ActivityType, int Points, string SubjectKey, DateTime OccurredOnUtc) (GetPointsOverviewHandler.cs:102-108), each member documented at :96-101.
  • Why it's built this way: one projected pass over the ledger feeds all four rollup figures, so the row is the union of what those figures need rather than the intersection; the narrowing to a public shape happens once, at the end.
  • Where it's used: the select argument of the single PointsEntry read (GetPointsOverviewHandler.cs:42-48), then the source for the per-activity grouping (:54-62), the recent tail (:69-80), the distinct participant count (:85), and the total payout (:86).

LiveChannelPublishQueue

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Live · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:14 · Level 2 · class (sealed, partial)

  • What it is: the in-process implementation of ILiveChannelPublishQueue, a bounded System.Threading.Channels channel holding up to 1024 pending broadcasts, plus the reader handle the hosted drain consumes.
  • Depends on: ILiveChannelPublishQueue and LiveChannelPublishWorkItem first-party; System.Threading.Channels (Channel, BoundedChannelOptions, BoundedChannelFullMode, ChannelReader<T>), ArgumentNullException and Interlocked from the BCL, and ILogger<LiveChannelPublishQueue> for the dropped-broadcast warning (LiveChannelPublishQueue.cs:1-2, :21).
  • Concept introduced, bounded channels and an explicit backpressure policy. An unbounded queue turns a slow consumer into a memory leak, so the capacity is fixed at 1024 (LiveChannelPublishQueue.cs:18). The interesting design choice is what happens when it fills: BoundedChannelFullMode.DropOldest (LiveChannelPublishQueue.cs:36), so a full queue discards the oldest item and still accepts the new one, which is why the write can never be refused and Enqueue has no outcome to return. The doc states the reasoning (LiveChannelPublishQueue.cs:6-13): live channel events are ephemeral, so under sustained backpressure the freshest broadcast is worth more than the oldest, and the request path must never block. [Rubric §12, Performance & Scalability] assesses bounded resource use under load, which the capacity plus drop policy gives. [Rubric §29, Resilience & Business Continuity] assesses graceful degradation: a slow Notification peer degrades to stale-but-recent broadcasts, not to a stalled or ballooning host. The inline comment sizes the capacity against the observed conference-day load of roughly 67 concurrent users (LiveChannelPublishQueue.cs:16-17), which is [Rubric §31, Cost/FinOps] thinking applied to memory: generous for the real load, not for an imagined one.
  • Walkthrough
    • private const int Capacity = 1024 (LiveChannelPublishQueue.cs:18), the bound, with the sizing rationale in the comment above it.
    • The _channel field (LiveChannelPublishQueue.cs:20) is created in the constructor by Channel.CreateBounded<LiveChannelPublishWorkItem> (LiveChannelPublishQueue.cs:33-40) with three options: FullMode = BoundedChannelFullMode.DropOldest (line 36), SingleReader = true (line 37, matching the one hosted drain, which is what makes delivery FIFO and per-session order preserving), and SingleWriter = false (line 38, because many concurrent request threads enqueue), plus the itemDropped: OnItemDropped callback (line 40). The comment above the call (:30-32) explains that this callback is the only way a DropOldest drop is observable, since TryWrite always succeeds under this mode.
    • public ChannelReader<LiveChannelPublishWorkItem> Reader => _channel.Reader (LiveChannelPublishQueue.cs:44) exposes only the read side to the drain worker; the writer side stays private, so nothing outside this class can bypass Enqueue.
    • public long DroppedCount => Interlocked.Read(ref _droppedCount) (LiveChannelPublishQueue.cs:47) is the running total of broadcasts discarded under backpressure since startup.
    • public void Enqueue(LiveChannelPublishWorkItem workItem) (LiveChannelPublishQueue.cs:55-59) null-guards with ArgumentNullException.ThrowIfNull(workItem) (line 57), then calls _channel.Writer.TryWrite(workItem) (line 58) without inspecting the result: under DropOldest the write evicts to make room rather than refusing, and the writer is never completed, so TryWrite cannot fail (LiveChannelPublishQueue.cs:50-54). It never blocks, which is the whole point of the contract.
    • OnItemDropped (LiveChannelPublishQueue.cs:61-65) is the only place a discard becomes visible: it increments _droppedCount (line 63) and emits the source-generated LogDropped warning naming the channel key, the event name, and the running total (LiveChannelPublishQueue.cs:67-70).
  • Why it's built this way: Channel<T> gives a lock-free, allocation-light producer/consumer queue in the BCL, so no third-party dependency is needed for a hand-off this simple. The Reader property is why the class is registered twice in DI (once as the concrete type, once as the interface, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:56-57): handlers only need the interface, but the drain needs the concrete reader, and both must resolve to the same singleton instance. The class is partial because LogDropped is a [LoggerMessage] source-generated method.
  • Where it's used: registered as a singleton in the Application-layer DependencyInjection (DependencyInjection.cs:55-56); its Reader is consumed by LiveChannelPublishProcessor in the Infrastructure layer, which is added with AddHostedService<LiveChannelPublishProcessor>() (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:21).

GetBookmarkedSessionIdsHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.GetBookmarkedSessionIds · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/GetBookmarkedSessionIds/GetBookmarkedSessionIdsHandler.cs:12 · Level 8 · class (sealed)

  • What it is: the query handler that answers GetBookmarkedSessionIdsQuery, returning a read-only dictionary mapping each of a user's bookmarked session ids to its bookmark id. This is the lightweight lookup a listing page uses to decide which rows render as bookmarked, and which bookmark id a delete would target.
  • Depends on: IUnitOfWork (constructor-injected, GetBookmarkedSessionIdsHandler.cs:12-13), the UserSessionBookmark aggregate it reads, the IReadRepository<TEntity, TIdentifierType> it obtains from it, and Result. It implements IQueryHandler<in TQuery, TResult>.
  • Concept, a projection read that never materializes the entity. [Rubric §12, Performance & Scalability] assesses whether reads pull only the columns they need; this handler projects, so the SQL selects two columns rather than whole rows. It resolves a read repository through GetReadRepository (GetBookmarkedSessionIdsHandler.cs:20) rather than the writable one, which signals intent and is also the DI-safe way to obtain a repository: injecting IRepository<TEntity, TIdentifierType> directly into a handler is the trap the persistence chapter warns about. [Rubric §6, CQRS & Event-Driven] and [Rubric §5, Vertical Slice]: the handler sits beside its query in the same use-case folder and returns a Result rather than throwing.
  • Walkthrough
    • The primary constructor injects IUnitOfWork (GetBookmarkedSessionIdsHandler.cs:12-13); the class closes the query-handler interface over Result<IReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>> (:13).
    • HandleAsync (GetBookmarkedSessionIdsHandler.cs:16-18) obtains the read repository for UserSessionBookmark (line 20), then calls GetProjectedAsync with select: b => new { b.SessionId, b.Id } and where: b => b.UserId == query.UserId (:22-25), so only the two ids for that user come back. The projection target is an anonymous type here rather than a named record, because it is consumed on the very next line.
    • It materializes the pairs into a dictionary keyed by SessionId with value Id (GetBookmarkedSessionIdsHandler.cs:27) and wraps it in Result.Success<IReadOnlyDictionary<...>> (line 29). There is no failure branch: this read cannot fail, so the Result is always a success.
  • Why it's built this way: the caller only needs "is this session bookmarked, and under which bookmark id", so projecting to a two-field shape and returning a dictionary avoids loading full aggregates and gives O(1) per-row lookups while rendering. The doc comment (GetBookmarkedSessionIdsHandler.cs:8-11) names the consumer explicitly: the unified Sessions page's per-row star state. Soft-deleted bookmarks are excluded by the global query filter, so a removed bookmark simply stops appearing without a predicate here.
  • Where it's used: dispatched from BookmarksController's GET /api/bookmarks/session-ids action (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:112-114) through the IQueryHandler<in TQuery, TResult> pipeline. Covered by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/UserSessionBookmarks/UseCases/GetBookmarkedSessionIdsHandlerTests.cs:13, which asserts the read repository is the one requested.
  • Caveats / not-in-source: the anonymous projection target is why that test verifies the repository call rather than the returned map; the test's own comment records that a mocked GetProjectedAsync cannot produce an anonymous type (GetBookmarkedSessionIdsHandlerTests.cs:20-21).

GetPointsOverviewHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.GetPointsOverview · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetPointsOverview/GetPointsOverviewHandler.cs:25 · Level 8 · class (sealed)

  • What it is: the query handler behind the organizer points dashboard. One read of the ledger produces four figures: how many attendees are playing, how many points the game has paid out, the per-activity breakdown, and the most recent awards.
  • Depends on: IUnitOfWork (primary constructor, GetPointsOverviewHandler.cs:25), the PointsEntry aggregate, its own OverviewRow projection, and the published shapes PointsOverviewDTO, PointsActivityTotalDTO, PointsEntryDTO, plus PointsActivityType. Implements IQueryHandler<in TQuery, TResult> and returns Result.
  • Concept, the in-memory fold and the reason it is not a GROUP BY. The class doc states the constraint and the trade-off (GetPointsOverviewHandler.cs:18-21): the Application layer has no EF Core reference, so a SQL GROUP BY "is not available to it", and at conference scale (about 100 attendees) one projected pass over the entries is cheaper than the round trips a per-figure query set would cost, with a materialised rollup named as the scale lever if the population ever changes that. [Rubric §3, Clean Architecture] assesses whether the inner layers stay free of infrastructure: the missing GROUP BY is a direct consequence of that rule, and the code says so rather than pretending the shape is accidental. [Rubric §12, Performance & Scalability] assesses whether the chosen shape is sized to the real workload (the observed conference-day load, not an imagined one). [Rubric §30, Compliance/Privacy/Data Governance] assesses what an operator surface exposes: the recent tail is projected into PointsEntryDTO, which carries no user id, so "an organizer sees THAT points were earned and for what, never by whom" (GetPointsOverviewHandler.cs:13-15).
  • Walkthrough
    • private const int MaxRecentCount = 100 (GetPointsOverviewHandler.cs:29) is the ceiling the handler enforces "regardless of what the caller asks for" (:28).
    • HandleAsync (GetPointsOverviewHandler.cs:32-34) null-guards the query (line 36) and clamps the tail length with Math.Clamp(query.RecentCount, 1, MaxRecentCount) (line 38), so both a zero and an int.MaxValue request land inside 1 to 100.
    • One read: GetReadRepository<PointsEntry, PointsEntryIdentifierType>() (line 40) then GetProjectedAsync selecting an OverviewRow per entry with asTracking: false (:41-50). There is no where, because the rollup is over every attendee.
    • The per-activity breakdown groups the rows by ActivityType, orders by the enum key, and sums points and counts per group into PointsActivityTotalDTO (GetPointsOverviewHandler.cs:52-63).
    • The recent tail orders newest-first with the entry id as the tie-break, takes recentCount, and projects into PointsEntryDTO (:67-81). The comment (:65-66) explains the tie-break: entries stamped in the same instant keep a stable order "rather than shuffling between refreshes of the organizer dashboard".
    • The result assembles ParticipantCount from rows.Select(row => row.UserId).Distinct().Count() (line 85) and TotalPointsAwarded from rows.Sum(row => row.Points) (line 86), then returns Result.Success(...) over the PointsOverviewDTO (:83-89).
  • Why it's built this way: the participant count is the only figure that needs attendee identity, and it needs it only as a cardinality, so reading the id into a private row and never projecting it outward gives the organizer the number without the names. Doing all four aggregations over one materialized list keeps the database round trips at one regardless of how many figures the dashboard grows.
  • Where it's used: resolved by PointsController (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:40) and invoked from the permission-gated overview endpoint (PointsController.cs:129-131). Its behavior is pinned by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/Points/UseCases/GetPointsOverviewHandlerTests.cs, including the distinct-attendee count (:17), the empty-ledger zeros (:35), the per-activity rollup (:65), the recent tie-break (:116), and the tail clamp theory (:135).

GetMyPointsHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.GetMyPoints · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetMyPoints/GetMyPointsHandler.cs:27 · Level 9 · class (sealed)

  • What it is: the query handler for GetMyPointsQuery. It reads the calling attendee's own ledger, sums it into a running total, reports whether they are on the leaderboard, and returns one page of entries.
  • Depends on: IUnitOfWork and ICurrentUserService (primary constructor, GetMyPointsHandler.cs:27-29), the PointsEntry and LeaderboardOptIn aggregates, PagingMath, and the MyPointsDTO / PointsEntryDTO shapes. Returns a Result and can produce an Error. Externals: none beyond the BCL.
  • Concept, scoping a read to the token instead of to a parameter. The class doc states the guarantee in its first sentence (GetMyPointsHandler.cs:13-15): the read is always scoped to the token's user id, "nothing in the query can widen it, so there is no parameter an attendee could tamper with to read another attendee's ledger". [Rubric §11, Security] assesses whether authorization is enforced where the data is fetched rather than only at the edge; here the filter predicate itself is built from the caller identity (:63), so even a mis-configured route cannot widen the result set. [Rubric §8, Data Architecture] assesses whether derived values are stored or computed: the doc (:15-22) explains that the total is summed on read rather than maintained on a user row, because "a materialised total can drift from its ledger, and a summed one cannot", with the materialised total named as the first scale lever if the population grows. [Rubric §12, Performance & Scalability] is the counterweight the same paragraph acknowledges: at about 100 attendees with a few dozen entries each, the sum is a trivial read.
  • Walkthrough
    • private const int MaxPageSize = 100 (GetMyPointsHandler.cs:32), the ceiling the handler enforces whatever the caller asks for (:30).
    • HandleAsync (GetMyPointsHandler.cs:35-37) null-guards the query (line 38), then resolves the caller with the shared guard currentUserService.RequireUserId("Points.Forbidden") (line 40). That extension member lives in MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:35) and collapses the read-then-null-check-then-fail block every per-user handler repeats: it returns Result.Success(userId) when the token carries a user, and otherwise one Error built from the module's code, the default message "Access denied.", and ErrorType.Forbidden (CurrentUserServiceExtensions.cs:43-45). The module supplies only the code, because the code names the module and the framework cannot know it (CurrentUserServiceExtensions.cs:21-24).
    • A failure short-circuits: return Result.Failure<MyPointsDTO>(caller.Errors) (GetMyPointsHandler.cs:42-45), which the controller renders as a 403 Problem Details response (PointsController.cs:52). On success the identifier is unwrapped (line 46).
    • Paging is clamped, not trusted: PagingMath.Clamp(query.PageNumber, query.PageSize, MaxPageSize) returns a (skip, take) pair (GetMyPointsHandler.cs:52). The comment (:48-50) records exactly what the helper buys: it floors a non-positive page number or size and range-checks the offset in 64-bit, so a hostile page number near int.MaxValue "yields the empty page it holds instead of a negative skip" (the helper itself returns (0, 0) in that case, MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/PagingMath.cs:42).
    • The ledger read projects straight into PointsEntryDTO with where: entry => entry.UserId == userId and asTracking: false (GetMyPointsHandler.cs:55-66).
    • A second read pulls the caller's leaderboard display names (GetMyPointsHandler.cs:70-75) and takes the first (:76). The comment (:67-68) notes that this read relies on the default query filter, so an opt-in the attendee soft-deleted by leaving the board correctly reports them as not on the leaderboard.
    • The page is ordered newest-first with the entry id as the tie-break, then skipped and taken in memory (GetMyPointsHandler.cs:81-88); the comment (:78-79) gives the concrete reason (one badge scan fanning out into awards stamped in the same instant).
    • The result (GetMyPointsHandler.cs:90-98) sets Total from entries.Sum(...) over every entry rather than the page (the inline comment at :91-92 makes that explicit), IsOnLeaderboard from whether a display name was found, LeaderboardDisplayName, and Entries.
  • Why it's built this way: reading the caller from ICurrentUserService rather than from the message is what lets the endpoint skip an ownership filter entirely; routing that read through the shared RequireUserId guard keeps every module's denied-caller failure the same shape while leaving the error code module-specific; and clamping through the shared PagingMath rather than a local Math.Min keeps the arithmetic (including the 64-bit offset check) in one tested place used by every paged read in the codebase.
  • Where it's used: resolved by PointsController (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:37) and invoked from GET /api/points/me (PointsController.cs:58-60). Its paging and total semantics are pinned by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/Points/UseCases/GetMyPointsHandlerTests.cs: the total sums every entry even when one page is returned (:23), the page walk neither repeats nor drops (:76), hostile paging arguments clamp instead of throwing (:102), a page number beyond the ledger returns the empty page that page holds (:120), no current user returns forbidden (:141), and the read never returns another attendee's entries (:153).
  • Caveats / not-in-source: the ordering, skip, and take happen after materialization, so the read pulls all of the caller's entries and pages them in memory. That is the intended trade for a per-attendee ledger at conference scale (the class doc's scale note, GetMyPointsHandler.cs:16-23), not an oversight, but it is a different shape from the server-side paging in GetUserBookmarksHandler.

GetUserBookmarksHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.GetUserBookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/GetUserBookmarks/GetUserBookmarksHandler.cs:17 · Level 9 · class (sealed)

  • What it is: the query handler for GetUserBookmarksQuery. It returns a page of a user's bookmarked sessions as DTOs, newest first, with an optional event filter resolved across the module boundary.
  • Depends on: IUnitOfWork, the cross-module ISessionBookmarkValidationService, IQueryableExecutor, and UserSessionBookmarkDTOMapper (all primary-constructor injected, GetUserBookmarksHandler.cs:17-21), plus PagingMath. It reads the UserSessionBookmark aggregate, produces UserSessionBookmarkDTOs, and returns a PagedCollectionResult<T> carrying PaginationMetadata. Externals: System.Linq.Expressions (:1).
  • Concept, server-side paging plus a cross-module lookup instead of a join. [Rubric §12, Performance & Scalability] assesses whether counting, ordering, and paging happen in the database rather than in memory; every step here does. [Rubric §7, Microservices Readiness] assesses cross-service reads: the event filter cannot be a SQL join because sessions live in the Conference database (ADR-006), so the handler fetches the event's session ids through ISessionBookmarkValidationService (a gRPC client once extracted, ADR-007) and filters on that id set. [Rubric §29, Resilience & Business Continuity] shows in how a Conference outage is handled: the code comment (GetUserBookmarksHandler.cs:40-42) states that a failure across the gRPC boundary propagates as a Result failure "so the request degrades gracefully instead of a raw 500".
  • Walkthrough
    • HandleAsync (GetUserBookmarksHandler.cs:24-26) first clamps the page: PagingMath.Clamp(query.PageNumber, query.PageSize, 500) (line 31) is the BR-11 cap mechanism, and the comment above it (:28-30) records that it also floors a non-positive page number or size and range-checks the offset in 64-bit.
    • It resolves the repository (line 33) and declares an Expression<Func<UserSessionBookmark, bool>> filter (line 36) so the predicate is built once and used by both the count and the page query.
    • When query.EventId.HasValue (line 38) it calls sessionValidationService.GetSessionIdsByEventAsync(...) across the module boundary (:43-44), returns Result.Failure with the propagated errors if that call failed (:45-46), materializes the ids into a list (line 48), and filters bookmarks to that user and that session-id set (line 50, BR-58). Otherwise the filter is just b => b.UserId == query.UserId (line 54).
    • It counts server-side with bookmarkRepo.CountAsync(filter, cancellationToken) (line 58, translated to SQL COUNT), then orders and pages server-side by pushing TableNoTracking.Where(filter).OrderByDescending(b => b.CreatedOn).ThenByDescending(b => b.Id).Skip(skip).Take(take) through IQueryableExecutor.ToListAsync (:63-70, translated to SQL ORDER BY plus OFFSET/FETCH). The Id tie-break is load-bearing and commented as such (:60-62): OFFSET/FETCH needs a total row order, or bookmarks created in the same instant "would otherwise repeat or vanish across pages".
    • It maps the page with UserSessionBookmarkDTOMapper.MapToDTOs (line 72), builds new PaginationMetadata(totalCount, take, Math.Max(query.PageNumber, 1)) (line 73) using the clamped take and a floored page number so the metadata describes what was actually returned, and returns Result.Success(new PagedCollectionResult<UserSessionBookmarkDTO>(dtos, metadata)) (line 75).
  • Why it's built this way: pushing count, order, skip, and take into the database keeps the read bounded and cheap even for a user with many bookmarks; resolving the event filter through a service interface (never a cross-database foreign key) is what makes the module independently deployable (ADR-006 and ADR-007). TableNoTracking keeps the paged read off the change tracker, and the whole read still honors the soft-delete query filter, so a deleted bookmark simply is not in the page or the count.
  • Where it's used: dispatched from BookmarksController's GET /api/bookmarks action (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:94-96), whose ownership is guarded by the OwnerOrAdminFilter configured for the module (BookmarksController.cs:85, ADR-033). Pinned by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/UserSessionBookmarks/UseCases/GetUserBookmarksHandlerTests.cs, which covers the event-filter path (:140), the failure propagation when the session lookup fails (:161), the over-large page size (:199, :310), the page number beyond the reachable offset (:230), and the negative page number and page size (:250, :269).
  • Caveats / not-in-source: whether ISessionBookmarkValidationService resolves to an in-process implementation or a gRPC client depends on the host composition, not on this file. Note also that this handler takes the writable repository via GetRepository (GetUserBookmarksHandler.cs:33) even though it only reads, unlike GetBookmarkedSessionIdsHandler, which uses GetReadRepository; the read itself still goes through TableNoTracking.

GetLeaderboardHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.GetLeaderboard · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:28 · Level 10 · class (sealed)

  • What it is: the query handler that builds the public leaderboard: the opted-in attendees with the highest points totals, ranked, truncated to the configured board length.
  • Depends on: IUnitOfWork and IOptions<PointsSettings> (primary constructor, GetLeaderboardHandler.cs:28-30), the LeaderboardOptIn and PointsEntry aggregates, its private projection OptInRow, and the published LeaderboardEntryDTO. Implements IQueryHandler<in TQuery, TResult>, returns Result. Externals: Microsoft.Extensions.Options (:1), StringComparer.Ordinal (BCL).
  • Concept, publishing a name only if its owner published it. The class doc (GetLeaderboardHandler.cs:13-16) is the design statement worth reading twice: only attendees who opted in appear, the name shown is "the snapshot they published at opt-in", and the handler "makes no call into Identity and reads no user record", so an attendee who never opted in is absent rather than present under a name they did not choose. [Rubric §30, Compliance/Privacy/Data Governance] assesses consent as a structural property rather than a checkbox: the board is built from the opt-in table outward, so non-participation is the default and cannot be undone by a bug in a filter. [Rubric §11, Security] assesses minimizing what a public surface can reveal: no Identity call means no user record is even in reach of this code path. A second design note (:19-23) covers ranking: ties keep distinct sequential ranks (1, 2, 3) rather than competition ranking (1, 1, 3), because the board is a fixed-length list of positions, and the tie-break is the published name compared ordinally so the order is "deterministic and repeatable across reads rather than dependent on whatever order the database returned". [Rubric §15, Best Practices & Code Quality] shows in that ordinal comparison: a culture-sensitive comparison would make the rendered order depend on the server's locale.
  • Concept, pushing the aggregate into the database with SumByAsync. The points side of this read is not folded in memory. SumByAsync (declared on the querier contract at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:184, which IReadRepository<TEntity, TIdentifierType> composes at IRepository.cs:330-331) is the persistence-neutral way to ask for a GROUP BY with SUM, so only one aggregate row per attendee crosses the wire instead of every ledger row. Its EF implementation groups with an element selector (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:165-172) so the caller's expression tree reaches the provider intact. [Rubric §3, Clean Architecture] is what makes this member necessary at all: the Application layer references no EF Core, so a handler that wants a grouped aggregate has no IQueryable of its own to group (IRepository.cs:156-161). [Rubric §12, Performance & Scalability]: the contract returns decimal, so an int points column widens on the way in and narrows on the way out (GetLeaderboardHandler.cs:52-53, :61), which the code comment notes no ledger total can overflow.
  • Walkthrough
    • HandleAsync (GetLeaderboardHandler.cs:33-35) reads the opt-ins first: a read repository for LeaderboardOptIn (line 39), then GetProjectedAsync into OptInRow with asTracking: false (:40-43). The comment above it (:37-38) records why there is no "is active" predicate: the default query filter excludes soft-deleted rows, so leaving the board removes the attendee from this read for free.
    • An empty opt-in list short-circuits to an empty successful board (GetLeaderboardHandler.cs:45-46), so a conference where nobody opted in costs one query rather than two.
    • The user ids are collected (line 48) and used to scope the ledger aggregate: a read repository for PointsEntry (line 54) then SumByAsync(entry => entry.UserId, entry => entry.Points, where: entry => userIds.Contains(entry.UserId), ...) (:55-59). The comment (:50-53) states both halves of the reasoning: only the opted-in attendees' entries are summed, and the sum happens in the database, so "one grouped SUM comes back per attendee rather than every ledger row for the fold to add up".
    • The decimal sums are narrowed into an int totals dictionary (GetLeaderboardHandler.cs:61).
    • var boardSize = Math.Max(settings.Value.LeaderboardSize, 0) (line 63) floors a misconfigured negative size at zero, so a bad configuration value publishes nothing instead of throwing inside Take. The setting itself defaults to 10 (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSettings.cs:40).
    • The board is composed with a collection expression (GetLeaderboardHandler.cs:65-77): order by total descending using totals.GetValueOrDefault(...) (line 68, so an opted-in attendee with no entries scores zero rather than being dropped), then by DisplayName with StringComparer.Ordinal (line 69), Take(boardSize) (line 70), and project each into a LeaderboardEntryDTO whose Rank is the positional index + 1 (:71-76).
    • return Result.Success(board) (line 79).
  • Why it's built this way: one narrow projected read for the opt-ins plus one grouped database aggregate for the totals keeps the round trips at two and keeps the ledger itself on the server; the opt-in table (not the ledger) drives the outer loop, which is what makes the consent rule structural. Reading the length from PointsSettings rather than from GetLeaderboardQuery is what keeps the published board size an organizer decision.
  • Where it's used: resolved by PointsController (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:38) and invoked from the parameterless GET /api/points/leaderboard endpoint (PointsController.cs:81-83). Its rules are pinned test by test in MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/Points/UseCases/GetLeaderboardHandlerTests.cs: a points-holder who never opted in is excluded (:18), an attendee who left the board is excluded (:33), nobody opted in yields an empty board (:47), ranks start at one in total order (:59), ties break on the ordinal published name (:78), an opted-in attendee with no points sits at zero (:94), the configured size truncates (:108), a zero or negative board size publishes nothing (:121, :135), and the name served is the one snapshotted at opt-in (:150).
  • Caveats / not-in-source: the aggregate is scoped with userIds.Contains(...) over a list built in memory, so the number of parameters in the translated SQL grows with the opted-in population. That is bounded by the conference-scale assumption stated in the class doc rather than by anything in this file. The final ordering, truncation, and ranking still happen in memory over one row per opted-in attendee.

AssemblyReference

MMCA.ADC.Engagement.Domain, MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Domain, MMCA.ADC.Engagement.Infrastructure · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/AssemblyReference.cs:5, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:5 · Level 0 · class (static)

One byte-identical copy per Engagement layer. This section covers the Domain and Infrastructure copies together; the API and Application copies are covered earlier in this chapter.

  • What it is: a two-field static class whose only job is to hand out the Assembly it was compiled into, plus that assembly's simple name, so anything that needs to say "scan the assembly this type lives in" gets a compiled anchor instead of a hand-typed assembly-name string.

    Type File:Line Notes (what differs)
    AssemblyReference (Domain) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/AssemblyReference.cs:5 namespace MMCA.ADC.Engagement.Domain (:3); no consumer anywhere in the repo today
    AssemblyReference (Infrastructure) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:5 namespace MMCA.ADC.Engagement.Infrastructure (:3); this is the copy the design-time migrations factory consumes

    The Engagement module carries four copies of this file (API, Application, Domain, Infrastructure). Each is eleven lines long and declares the same two fields at lines 7 and 8; only the namespace on line 3 differs. What actually distinguishes them is which assembly typeof(AssemblyReference).Assembly resolves to, and therefore which call sites can use them.

  • Depends on: System.Reflection (BCL), imported at line 1 of each file. Nothing first-party, which is why both copies sit at Level 0 even though one of them lives in the module's outermost non-presentation layer.

  • Concept: the framework-level explanation of assembly-marker types lives with the shared copy in AssemblyReference; these are the Engagement Domain and Infrastructure instances of that idiom. [Rubric §2, Design Patterns] assesses whether a recurring problem is solved with a recognized, uniform idiom rather than an ad-hoc one each time: every layer of every ADC module exposes the same pair of fields, so "point a scanner at this layer" has exactly one spelling across the repo. [Rubric §1, SOLID] (Dependency Inversion) is the other half: the migrations host depends on a marker declared for that purpose, not on some real EF configuration class it happens to know about, so renaming or deleting an entity configuration can never silently empty a scan.

  • Walkthrough: two public static readonly fields per copy, both resolved once at type initialization.

    • Assembly (AssemblyReference.cs:7) is typeof(AssemblyReference).Assembly. Because the marker is declared inside its own project, the Infrastructure copy resolves to the MMCA.ADC.Engagement.Infrastructure assembly, the one holding the ten EntityConfiguration classes under MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/, and the Domain copy resolves to the assembly holding the module's aggregates.
    • AssemblyName (AssemblyReference.cs:8) is Assembly.GetName().Name ?? string.Empty. The null-coalesce is what keeps the field non-null under nullable-reference-type analysis, since AssemblyName.Name is declared nullable even though a loaded assembly always reports one.
  • Why it's built this way: a typeof cannot go stale the way a string literal can, and because each layer declares its own copy, a scan can be aimed at one layer without that layer's consumers taking a reference to any other. The four-copy shape is deliberate duplication of a token, not duplicated logic: there is no behavior here to keep in sync, which is also why an unused copy costs nothing.

  • Where it's used: the Infrastructure copy is the one the EF Core design-time factory points at when it tells the module's SQLServerDbContext where to find entity configurations: options.AddConfigurationAssembly(typeof(MMCA.ADC.Engagement.Infrastructure.AssemblyReference).Assembly) at MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Engagement/DesignTimeSQLServerDbContextFactory.cs:45, the per-service migrations project that owns the ADC_Engagement database (ADR-006). That call site names the type through its full namespace, which is why the Infrastructure copy specifically is load-bearing: swapping in the Domain copy would point the scan at an assembly with no configurations in it. There are four such per-service migrations projects under MMCA.ADC/Source/Hosting/ (Conference, Engagement, Identity, Notification), one per database.

  • Caveats / not-in-source: the Domain copy has no consumer at all. The architecture-fitness map does not use either marker: MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:44 pins the Domain assembly with a real aggregate type (typeof(Engagement.Domain.UserSessionBookmarks.UserSessionBookmark).Assembly) and :46 pins Infrastructure with Assembly.Load("MMCA.ADC.Engagement.Infrastructure"), a name string. So the marker's only production consumer today is the one design-time factory line above.

ClassReference

MMCA.ADC.Engagement.Domain, MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Domain, MMCA.ADC.Engagement.Infrastructure · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/AssemblyReference.cs:11, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:11 · Level 0 · class

One byte-identical copy per Engagement layer. This section covers the Domain and Infrastructure copies; the API and Application copies are covered earlier in this chapter.

  • What it is: an empty, instantiable class declared at the bottom of the same file as the static marker above, existing purely so the assembly can be named in a generic type argument.

    Type File:Line Notes (what differs)
    ClassReference (Domain) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/AssemblyReference.cs:11 namespace MMCA.ADC.Engagement.Domain (:3); declared for symmetry, no call site today
    ClassReference (Infrastructure) MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/AssemblyReference.cs:11 namespace MMCA.ADC.Engagement.Infrastructure (:3); declared for symmetry, no call site today
  • Depends on: nothing. public class ClassReference { } is the whole declaration in both files.

  • Concept: the companion half of the marker pattern, introduced at framework level under ClassReference. C# forbids a static class as a generic type argument, so a registration helper shaped as Scan<TAssemblyMarker>() cannot accept AssemblyReference; the empty non-static class fills that slot without weakening the static one. [Rubric §33, Developer Experience] assesses how much ceremony the inner loop costs: because every layer of every module carries the identical pair, a developer adding a module writes the same two lines and gets both call shapes for free.

  • Walkthrough: no members. The only meaningful property of either copy is the assembly it belongs to, which a scanner reads as typeof(ClassReference).Assembly inside the generic helper.

  • Why it's built this way: co-locating both markers in one file makes the pair a single copy-paste unit when a new layer is created, and keeps the "which one do I pass?" answer purely mechanical (static handle for typeof(...).Assembly, empty class for <TMarker>).

  • Where it's used: neither of these two copies is referenced anywhere in the repository. The generic-marker call site in the Engagement module is the Application copy, services.ScanModuleApplicationServices<ClassReference>() at MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88, which is what discovers the module's domain-event handlers, mappers, command/query handlers, and validators by convention. The Domain layer has nothing to scan for, and the Infrastructure layer registers its one hosted service explicitly instead (see DependencyInjection below).

  • Caveats / not-in-source: both copies are unreferenced. They exist so the marker file is identical in every layer, not because something consumes them; nothing in the build fails if either is deleted. Read nothing here as saying they are wired up.

EngagementFeatures

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/EngagementFeatures.cs:8 · Level 0 · class (static)

  • What it is: the module's seven feature-flag names as const strings. Each constant is simultaneously the key read from the "FeatureManagement" configuration section and the argument to a [FeatureGate(...)] attribute on a controller, so a flag flip in configuration turns a whole surface off without a code change.
  • Depends on: nothing at all, first-party or external. The file has no using directives (EngagementFeatures.cs:1 is the namespace declaration). That is what lets Shared-layer callers, API controllers, and configuration authors all name the same string without any of them referencing each other.
  • Concept, module-scoped feature flags as compile-time constants. The sibling explanation for the pattern lives with ConferenceFeatures; this section covers what is specific to Engagement. Two mechanisms meet here. First, const string (not static readonly) means the value is inlined at the use site, which is why it can be used in an attribute argument and in an [InlineData] in a test. Second, the runtime behavior of a disabled flag is not the default from Microsoft.FeatureManagement.Mvc: MMCA.Common registers DisabledFeatureHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/DisabledFeatureHandler.cs:13), which sets an RFC 9457 ProblemDetails body with Status = 404 and the title "Feature not available" (:18-26). So a gated route answers 404 in the same envelope as every other failure from ApiControllerBase, and the endpoint reads to an outside caller as simply not existing rather than as forbidden. [Rubric §9, API & Contract Design] assesses whether concerns like this are applied uniformly at one point rather than re-implemented per endpoint: the gate is a single attribute on a controller class, and the disabled response shape is one handler in the framework. [Rubric §11, Security] shows in the choice of 404 over 403: a disabled surface leaks nothing about whether the capability exists. [Rubric §29, Resilience and Business Continuity] applies to the conference-day intent recorded in the doc comments: these are kill switches an organizer can throw mid-event.
  • Walkthrough: seven constants, all prefixed Engagement. so keys stay unambiguous in a shared configuration file.
    • SessionBookmarks = "Engagement.SessionBookmarks" (EngagementFeatures.cs:15) gates create, list, and delete for the personal schedule; applied at BookmarksController (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:32).
    • LivePolls = "Engagement.LivePolls" (:22) gates the conference-day live layer (author, open, close, vote, results); applied at LivePollsController (LivePollsController.cs:43).
    • CheckIn = "Engagement.CheckIn" (:29) gates QR badge check-in (my badge, scan, manual, attendance stats); applied at CheckInsController (CheckInsController.cs:37).
    • SponsorVisits = "Engagement.SponsorVisits" (:37) gates the attendee-facing sponsor booth-visit endpoint (CheckInsController.cs:134). The doc comment (:31-36) names the operational contrast worth remembering: this flag makes every printed sponsor QR inert, while setting the Points:SponsorVisit award (20 points at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/appsettings.json:31) to zero is the softer switch that keeps the visit recorded but stops the award.
    • RoomCheckIn = "Engagement.RoomCheckIn" (:44) gates the attendee-facing room self check-in endpoint (CheckInsController.cs:165). The comment (:39-43) states why it is not folded into SponsorVisits: the two surfaces are printed and retired independently, so each needs its own switch.
    • Points = "Engagement.Points" (:51) gates the whole points surface (my points, leaderboard, opt-in, organizer overview); applied at PointsController (PointsController.cs:34).
    • SessionQA = "Engagement.SessionQA" (:58) gates session Q&A (submit, list, moderate, upvote); applied at SessionQuestionsController (SessionQuestionsController.cs:35).
    • Note the nesting on CheckInsController: the class carries [FeatureGate(EngagementFeatures.CheckIn)] (:33) and two of its actions carry a second gate (:130, :161), so those two routes need both flags on. Turning CheckIn off retires the badge surface and the two attendee self-serve routes together; turning SponsorVisits off retires only the booth game.
  • Why it's built this way: ADR-031 is the decision record. Flag granularity here follows a printed-artifact rule rather than a code-structure rule: each constant corresponds to something an organizer might need to retire on its own during a live event, which is why sponsor visits and room check-in are separate constants even though they share a controller. Keeping the constants in the Shared assembly (not the API assembly) is what lets tests and other layers name a flag without taking a controller dependency.
  • Where it's used: as [FeatureGate(...)] arguments on the five Engagement controllers listed above, and as configuration keys. All seven appear in the standalone service host's "FeatureManagement" section, all set to true: MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/appsettings.json:17-25. The constants are also asserted directly in the API tests, for example EngagementFeatures.Points at MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/PointsControllerTests.cs:283 and the per-action gate theory at MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Controllers/CheckInsControllerTests.cs:477-478, which pins that RecordSponsorVisitAsync carries SponsorVisits and RecordRoomCheckInAsync carries RoomCheckIn.
  • Caveats / not-in-source: the class doc comment (EngagementFeatures.cs:3-7) says the constants are used with [FeatureGate] attributes and the IFeatureGated marker interface (:6). The attribute half is real; the marker half is not exercised by this module. No Engagement command implements IFeatureGated today (the only ADC implementor is Conference's RefreshFromSessionizeCommand, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13), so every Engagement flag is enforced at the HTTP boundary only, never inside the CQRS pipeline. Separately, the "FeatureManagement" section shown above is the per-service host's configuration; how the flags resolve in any other composition depends on that host's configuration and is not determinable from these files.

EngagementPermissions

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Authorization · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Authorization/EngagementPermissions.cs:9 · Level 0 · class (static)

  • What it is: the module's capability vocabulary: three permission strings plus an All list. Endpoints are gated on these capabilities rather than on role names, so "who can do what" is decided in one registration call instead of being spelled out across controllers.
  • Depends on: nothing first-party; only IReadOnlyList<string> and a collection expression from the BCL. The consumers are elsewhere: HasPermissionAttribute on the controllers and IPermissionRegistry behind the grants.
  • Concept, permission-based authorization instead of role checks. The framework machinery (the attribute, the registry, Grant) is taught under PermissionRegistry and decided in ADR-020; the module-specific idea worth teaching here is the shape of the vocabulary. The class doc (EngagementPermissions.cs:4-7) makes two commitments: endpoints require capabilities, not roles, and the values are stable string identifiers because they may end up in tokens or logs. That second point is why these are not an enum: the wire and the log are part of the contract. [Rubric §11, Security] assesses whether authorization is expressed as least-privilege capability rather than as an identity shortcut, and whether the grant surface is small enough to audit; three constants and one Grant call per role is that surface. [Rubric §9, API and Contract Design] applies to the naming: engagement:live:manage is module:resource:action, so a permission is self-describing wherever it surfaces. [Rubric §15, Best Practices & Code Quality] shows in All: adding a fourth capability grants it to organizers and admins automatically, because the grants spread the list rather than enumerating members.
  • Walkthrough: three constants and a derived list. Each constant's doc comment records not just what it grants but what deliberately does not need it, which is the more useful half.
    • LiveManage = "engagement:live:manage" (EngagementPermissions.cs:16): author, open, close, and delete live polls, and (in Wave 2) moderate session Q&A. The comment (:11-15) records the important exclusion: speakers get session-scoped rights through the speaker-assignment check in the handlers, not through this permission, so holding it is an organizer-level capability rather than the only way to act on a poll.
    • CheckInManage = "engagement:checkin:manage" (:23): scan an attendee badge, record a manual check-in, read the attendance rollup. The comment (:18-22) records why fetching your own badge is authenticated-only: an attendee needs no capability to see their own QR.
    • PointsViewOverview = "engagement:points:view-overview" (:30): read the organizer points rollup (participation, payouts per activity, recent activity). Again the comment (:25-29) states the boundary: an attendee's own points and the opt-in leaderboard are authenticated-only, and only the organizer rollup is gated.
    • All (:33-38) is a public static IReadOnlyList<string> initialized from a collection expression listing the three constants. It is a get-only property over an immutable-facing interface, so a caller can enumerate it but not append to it.
  • Why it's built this way: the three-capability split follows the same rule as the flag list, but along a different axis: a capability exists where an organizer-only action would otherwise have to be spelled as a role check inside a controller. Everything an attendee does to their own data stays on plain authenticated access plus the ownership filter, which is why there is no engagement:bookmarks:* capability at all: bookmark ownership is enforced by OwnerOrAdminFilter (ADR-033), configured with ADC's ownership vocabulary in the module's own API registration (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:45-56), not by a permission.
  • Where it's used: granted in AddModuleEngagementAPI (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:58-62), where permissions.Grant(RoleNames.Organizer, [.. EngagementPermissions.All]) (:60) and the identical RoleNames.Admin line (:61) are the module's entire role-to-permission mapping. Consumed as attribute arguments at MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:121 (PointsViewOverview), LivePollsController.cs:150 and :168 (LiveManage), and CheckInsController.cs:79, :99, :179 (CheckInManage). Pinned by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/Authorization/EngagementPermissionGrantsTests.cs, which builds the real registry from AddModuleEngagementAPI() (:20) and asserts that organizers (:25) and admins (:37) hold every capability while attendees (:58) and content editors (:70) hold none.
  • Caveats / not-in-source: whether a given caller actually presents the Organizer or Admin role depends on token issuance in the Identity module, not on anything in this file. The LiveManage comment says Wave 2 extends it to session Q&A moderation; the moderation actions themselves live on SessionQuestionsController and are covered in the live-layer chapter.

CheckInResultDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInResultDTO.cs:8 · Level 0 · record class

  • What it is: the response body of both organizer check-in endpoints, who the badge resolved to, whether this was a repeat, and when the check-in was recorded.
  • Depends on: the UserIdentifierType alias (module-specific global using, see primer §2). Externals: BCL DateTimeOffset.
  • Concept, a repeat is a success, not a conflict. [Rubric §9, API & Contract Design] assesses how a write's idempotent semantics are expressed to clients. The obvious design answers 409 on a second scan; this one answers 200 with AlreadyCheckedIn = true (CheckInResultDTO.cs:3-7). The reason is operational: the organizer scanning a lanyard needs the attendee's identity on screen either way, and at a door a second scan is a normal event rather than a client error to correct. Encoding the repeat as data on a success means the calling UI has one code path, and the status code stops carrying business meaning. CheckInsController states the same rule at its POST (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:64-70).
  • Walkthrough (CheckInResultDTO.cs:8-18): three required init members. UserId (line 11) is the attendee the credential resolved to, which is the whole point of the scan response, since the organizer scans an opaque code and needs a person back. AlreadyCheckedIn (line 14) is the repeat flag. CheckedInOn (line 17) is the recorded time and, on a repeat, deliberately the original time rather than "now", so the UI can say "already checked in at ...". CheckInProcessor fills it from the existing row on the repeat branch (.../CheckIns/Services/CheckInProcessor.cs:62-67) and from the new entity otherwise (:78-83).
  • Why it's built this way: required + init makes every field non-optional at construction and the value immutable afterwards, so no handler can return a partially filled outcome. The type carries no session or event id because the caller supplied those; only the facts the server discovered travel back.
  • Where it's used: returned by CheckInAttendeeHandler and ManualCheckInHandler through CheckInProcessor, surfaced by CheckInsController on POST and POST manual (CheckInsController.cs:73, :89), and consumed client-side by ICheckInUIService/CheckInService (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/ICheckInUIService.cs:27, :33) into CheckInScan's result list (CheckInScan.razor.cs:212).

CheckInScope

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInScope.cs:11 · Level 0 · enum

  • What it is: what a single check-in row attests to, arrival at the event, attendance of one session, or a visit to one sponsor booth.
  • Depends on: nothing. It is the discriminator every other type in this family branches on.
  • Concept, one aggregate with a scope discriminator, and why the numbers are frozen. [Rubric §4, DDD] assesses whether the model reflects the domain's real distinctions rather than inventing tables per surface, and [Rubric §8, Data Architecture] how storage encodes those distinctions. The three scopes share a row shape, an idempotency rule and an attendance query, and differ only in which target is required, so they live on one CheckIn aggregate rather than three (CheckInScope.cs:3-5). The cost is one persistence constraint that is easy to miss: the uniqueness rules are filtered unique indexes, and an index filter is SQL, so it cannot read the enum. CheckInConfiguration therefore names the numbers literally, "[Scope] = 0", "[Scope] = 1", "[Scope] = 2" (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:22-26), and pairs each with its target: unique on (UserId, EventId) for the event scope (:49-51), on (UserId, SessionId) for the session scope (:54-56), on (UserId, SponsorId) for the sponsor scope (:60-62). That is why the type doc calls the numbers load-bearing (CheckInScope.cs:6-9): a scope is added, never renumbered, or the shipped indexes silently start guarding the wrong rows.
  • Walkthrough (CheckInScope.cs:11-24): Event = 0 (line 13), arrival at the event itself, documented as kept for a future info-desk or points-activation use rather than as the working door process; Session = 1 (16), the scope the conference-day scanning flow actually uses; Sponsor = 2 (23), a booth visit recorded by the attendee themselves after scanning a printed deep-link QR, which is the one scope where the recording user and the visiting user are the same person.
  • Why it's built this way: the conference runs door and arrival check-in through TicketLeap, so Event is deliberately not a door process here, a point CheckInsController repeats where a reader is most likely to assume otherwise (CheckInsController.cs:27-31). Keeping the unused scope in the enum costs one number and keeps the model honest about what a check-in can mean.
  • Where it's used: on the CheckIn entity and in every predicate that finds an existing row (CheckInProcessor.cs:146-148, RecordRoomCheckInHandler.cs:83, RecordSponsorVisitHandler.cs:81); in the request DTOs CheckInAttendeeRequest and ManualCheckInRequest; in the attendance query (GetAttendanceStatsHandler.cs:28, :33); and as the toggle value on CheckInScan, which offers only Session and Event (CheckInScan.razor:40-41), since a sponsor visit is self-recorded and never scanned by an organizer.
  • Caveats / not-in-source: the enum never travels on the integration event; see CheckInScopeNames for the wire form and why.

CheckInSettings

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInSettings.cs:11 · Level 0 · sealed class

  • What it is: the module's check-in policy options, bound from the "CheckIns" configuration section. Today it holds exactly one knob, the room self check-in grace window.
  • Depends on: no first-party types. Externals: the options pattern (Microsoft.Extensions.Options, bound at the host).
  • Concept, the options class as a policy boundary. [Rubric §17, DevOps & Deployment] assesses whether configuration is typed and injected rather than read ad hoc, and [Rubric §7, Microservices Readiness] whether a module owns its own policy. Both are visible in one decision here: the grace window lives in Engagement, not Conference. Conference answers "which session is this room hosting"; this module decides "how early does that count" and passes its own number into the cross-module call (CheckInSettings.cs:6-9, and RecordRoomCheckInHandler.cs:48-52). Had the number lived in Conference, retuning check-in behavior would mean redeploying a service that has no stake in it.
  • Walkthrough (CheckInSettings.cs:11-22): SectionName (line 14) is the const "CheckIns", kept next to the type so the binding call cannot mistype it. RoomCheckInGraceMinutes (line 21) is an init-only int defaulting to 15, with the rationale in its own doc comment: attendees scan while the previous talk is clearing out, so a zero-grace window would reject the whole pre-session queue.
  • Why it's built this way: the binding is deliberately not ValidateOnStart. The Engagement service host states the reason inline: a missing "CheckIns" section binds the grace window to a working default, which is not a misconfiguration worth refusing to boot over mid-conference (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:150-155). The default living on the property, rather than in an appsettings file, is what makes that stance safe. [Rubric §29, Resilience & Business Continuity]: the failure mode of bad configuration here is a slightly wrong window, not a dead host.
  • Where it's used: registered with services.AddOptions<CheckInSettings>().Bind(...) (Program.cs:154-155) and injected as IOptions<CheckInSettings> into RecordRoomCheckInHandler (.cs:28), which passes settings.Value.RoomCheckInGraceMinutes to IEventLiveValidationService.GetCurrentRoomSessionInfoAsync (.cs:51-53). No other handler reads it.
  • Caveats / not-in-source: covered by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Shared.Tests/CheckIns/CheckInSettingsTests.cs. Whether a deployed environment overrides the 15-minute default is a configuration fact, not a source fact: not determinable from this file.

SessionAttendanceDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.Attendance · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Attendance/SessionAttendanceDTO.cs:6 · Level 0 · record class

  • What it is: one row of the attendance rollup, a session and how many attendees are checked in to it.
  • Depends on: the SessionIdentifierType alias. Externals: none.
  • Concept: a leaf row type of a composed read model; the rollup concept itself is taught at AttendanceStatsDTO. [Rubric §9, API & Contract Design]: the row carries an identifier and a count, no session title, so the reporting surface joins names itself rather than making the Engagement service reach into Conference for display text on every rollup.
  • Walkthrough (SessionAttendanceDTO.cs:6-13): two required init members, SessionId (line 9) and Count (line 12). Rows are produced by grouping a SessionId-only projection in memory and ordering by session identifier (GetAttendanceStatsHandler.cs:37-44), which is why AttendanceStatsDTO documents the list as ordered by session identifier.
  • Where it's used: only inside AttendanceStatsDTO.SessionAttendance; the OrganizerAttendance page turns the list into display rows via BuildSessionRowsAsync (.../UI/Pages/CheckIn/OrganizerAttendance.razor.cs:75), which is where session titles are attached (SessionAttendanceRow).
  • Caveats / not-in-source: sessions with zero check-ins never appear, since the projection only sees recorded rows.

AttendanceStatsDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.Attendance · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Attendance/AttendanceStatsDTO.cs:7 · Level 1 · record class

  • What it is: one event's attendance rollup for the organizer dashboard, the event-scoped arrival count plus one SessionAttendanceDTO row per session that has at least one session-scoped check-in.
  • Depends on: SessionAttendanceDTO and the EventIdentifierType alias. Externals: BCL IReadOnlyList<T>.
  • Concept, the purpose-built read model. [Rubric §6, CQRS & Event-Driven] assesses whether reads are served by shapes designed for the question being asked rather than by generic entity lists, and [Rubric §12, Performance & Scalability] whether the work sits where it is cheapest. This DTO is the answer to exactly one screen. GetAttendanceStatsHandler builds it from two focused repository calls on the read repository (unitOfWork.GetReadRepository<CheckIn, ...>, .cs:25): a CountAsync for the event scope (:27-29), and a SessionId-only projection filtered in SQL by event, scope and a present session id (:31-35), grouped and counted in memory (:37-44). No CheckIn entity is ever materialized, and the projection stays small by construction, one small value per recorded check-in of the event (.cs:9-14).
  • Walkthrough (AttendanceStatsDTO.cs:7-17): three required init members. EventId (line 10) echoes the queried event. EventAttendance (13) is the count of distinct attendees at the event scope, which is distinct by construction because the filtered unique index permits one event-scoped row per attendee (CheckInConfiguration.cs:49-51). SessionAttendance (16) is the ordered per-session list.
  • Why it's built this way: session titles are not in this payload, so Engagement answers the rollup without a synchronous Conference read on a dashboard refresh, which keeps the cross-service dependency off the hot path (ADR-007, ADR-008). The page joins names once it has the list.
  • Where it's used: returned by GetAttendanceStatsHandler for a GetAttendanceStatsQuery, surfaced by GET stats?eventId= on CheckInsController (.cs:154-161) behind the CheckInManage permission (:157); fetched client-side by CheckInService (.../UI/Services/CheckInService.cs:87) and held by the OrganizerAttendance page (.../UI/Pages/CheckIn/OrganizerAttendance.razor.cs:29).
  • Caveats / not-in-source: sponsor-scoped visits are counted in neither figure; the query filters on CheckInScope.Event and CheckInScope.Session only (GetAttendanceStatsHandler.cs:29, :33).

CheckInAttendeeRequest

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInAttendeeRequest.cs:8 · Level 1 · record class

  • What it is: the organizer scanning request, a scanned or typed badge credential plus what the check-in attests to and its target.
  • Depends on: CheckInScope, BadgePayload (which parses the Credential string), and the EventIdentifierType / SessionIdentifierType aliases.
  • Concept, the scope-conditional request. [Rubric §24, Forms/Validation/UX Safety] and [Rubric §9, API & Contract Design] assess whether input rules are declared once and enforced before business logic runs. This DTO carries a discriminator and two possible targets, which is exactly the shape where "which field is required" gets re-litigated in every handler. It is stated once instead, in CheckInAttendeeRequestValidator: Credential not empty (CheckIn.Credential.Required), Scope IsInEnum (CheckIn.Scope.Invalid), EventId non-default (CheckIn.EventId.Required), and SessionId NotNull only .When(x => x.Scope == CheckInScope.Session) (.../CheckInAttendee/CheckInAttendeeRequestValidator.cs:11-34). The comment at line 28 is the rule worth memorizing: the event stays required for both scopes, only the session is scope-conditional. Every rule attaches a stable WithErrorCode so clients branch on a code rather than parse prose.
  • Walkthrough (CheckInAttendeeRequest.cs:8-21): Credential (line 9), the raw scanned text, deliberately a string rather than a Guid so a prefixed payload and a hand-typed bare code both bind, with BadgePayload.TryExtractCredential doing the parsing server-side (CheckInAttendeeHandler.cs:41); Scope (12); EventId (15), required for both scopes; and the nullable SessionId (18), required when the scope is Session.
  • Why it's built this way: the event id the client sends is not the last word. For a session check-in CheckInProcessor takes the owning event back from Conference and lets that value win, because a stale event context on the organizer's screen would otherwise file the attendance under an event where the rollup would never find it again (CheckInProcessor.cs:47-51). This is a good illustration of a request field being accepted for routing but not trusted as truth.
  • Where it's used: bound by POST on CheckInsController (.cs:67-74) behind the CheckInManage permission (:68), handled by CheckInAttendeeHandler, and built client-side by CheckInScan (.../UI/Pages/CheckIn/CheckInScan.razor.cs:151-156), which sends SessionId only when the scope toggle is on Session.

CheckInDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInDTO.cs:8 · Level 1 · record class

  • What it is: the standard read projection of a recorded CheckIn row, the module's generic DTO for the entity as opposed to the purpose-built outcome and rollup shapes above.
  • Depends on: IBaseDTO<TIdentifierType> (implemented interface, CheckInDTO.cs:1, :8), CheckInScope, and the CheckInIdentifierType / UserIdentifierType / EventIdentifierType / SessionIdentifierType aliases. Externals: BCL DateTimeOffset.
  • Concept, the entity DTO contract. [Rubric §3, Clean Architecture] assesses whether the domain entity stays behind a boundary, and [Rubric §9, API & Contract Design] whether transferable shapes are uniform across a codebase. Implementing IBaseDTO<TIdentifierType> is what plugs this record into the framework's generic mapping and API machinery: it promises an Id of the entity's identifier type, which is the single assumption the shared base classes make. The mapper is generated, not hand-written, by Mapperly: CheckInDTOMapper is a [Mapper]-annotated partial implementing IEntityDTOMapper<CheckIn, CheckInDTO, CheckInIdentifierType> with MapToDTO left partial for the source generator and only the collection overload written by hand (.../CheckIns/DTOs/CheckInDTOMapper.cs:11-23). See ADR-001 for the mapping stance.
  • Walkthrough (CheckInDTO.cs:8-30): Id (line 11, the IBaseDTO member), UserId (14), Scope (17), EventId (20), the nullable SessionId (23, set only for session-scoped rows), CheckedInByUserId (26, the user who recorded it), and CheckedInOn (29). All required init except the nullable session id.
  • Why it's built this way: keeping a conventional entity DTO next to the specialized ones means the entity has a standard projection available the moment a generic read surface is needed, without a handler inventing a new shape under time pressure.
  • Where it's used: nothing but CheckInDTOMapper references it in production code today (repo-wide search over MMCA.ADC finds the type in CheckInDTO.cs and the mapper only). None of the check-in endpoints return it: CheckInsController returns MyBadgeDTO, CheckInResultDTO, SponsorVisitResultDTO, RoomCheckInResultDTO and AttendanceStatsDTO (.cs:35-40), and the privacy export uses its own UserEngagementCheckInExportDTO.
  • Caveats / not-in-source: the DTO omits SponsorId, which the entity carries and CheckInConfiguration indexes (.cs:60-62), so a sponsor-scoped row projected through this DTO would lose its target. Why the pairing is kept while unused is not stated in source: not determinable from source.

ManualCheckInRequest

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/ManualCheckInRequest.cs:7 · Level 1 · record class

  • What it is: the organizer's manual check-in fallback body, used when a badge cannot be scanned (no camera on the head, a dead phone, or an attendee found by name search instead).
  • Depends on: CheckInScope and the UserIdentifierType / EventIdentifierType / SessionIdentifierType aliases.
  • Concept: structurally the twin of CheckInAttendeeRequest with the credential replaced by a UserId; see that section for the scope-conditional validation concept. The two validators are line-for-line parallel apart from the first rule, ManualCheckInRequestValidator opening with UserId NotEqual(default(UserIdentifierType)) and error code CheckIn.UserId.Required (.../ManualCheckIn/ManualCheckInRequestValidator.cs:13-16) where the scan validator requires a non-empty credential.
  • Walkthrough (ManualCheckInRequest.cs:7-20): UserId (line 10), Scope (13), EventId (16), nullable SessionId (19).
  • Why it's built this way: keeping the fallback as its own DTO and endpoint (rather than an optional-credential variant of the scan request) is what makes the two paths independently authorizable and independently auditable, while CheckInProcessor keeps the actual rules stated once for both (CheckInProcessor.cs:10-15). [Rubric §15, Best Practices & Code Quality]: two thin entry points over one shared core beats one entry point with a mode flag.
  • Where it's used: bound by POST manual on CheckInsController (.cs:87-94) behind the CheckInManage permission (:88), handled by ManualCheckInHandler, and built by CheckInScan's fallback path (CheckInScan.razor.cs:186-191).

DependencyInjection

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:9 · Level 4 · class (static)

One DependencyInjection per Engagement layer. This section is the Infrastructure layer's; the API, Application, UI, and Contracts layers each have their own, covered elsewhere in this chapter.

  • What it is: the Engagement Infrastructure layer's registration extension. It is the smallest of the module's composition roots: one method that registers one hosted service.
  • Depends on: IServiceCollection and AddHostedService (Microsoft.Extensions.DependencyInjection, DependencyInjection.cs:1) and LiveChannelPublishProcessor from the layer's own Live namespace (:2, :21).
  • Concept: this is the standard extension(IServiceCollection) registration idiom used throughout the codebase, taught once in the primer under C# extension(T) types; the extension(IServiceCollection services) block at DependencyInjection.cs:11 is what makes AddModuleEngagementInfrastructure() read as an instance method on the collection. What is worth teaching here is the division of labor the file records. Its doc comment (:13-18) is explicit that the queue itself is registered by the Application layer and only the drain worker is registered here, and that is exactly what the code does: MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:56-57 registers LiveChannelPublishQueue as a singleton and aliases ILiveChannelPublishQueue onto the same instance, while this file registers only the reader. [Rubric §3, Clean Architecture] assesses whether the dependency direction survives composition: the producer side of that boundary (the queue and its interface) is an Application-layer concern that command handlers enqueue to, and the consumer side (a BackgroundService that resolves ILiveChannelPublisher out of a scope) is infrastructure, so the split is not arbitrary tidiness. [Rubric §5, Vertical Slice] applies to the whole per-layer pattern: each layer of the slice declares its own wiring and the host calls one method. [Rubric §12, Performance and Scalability] is the reason the worker exists at all: the queue takes the broadcast off the command hot path, so a slow Notification peer cannot lengthen a poll-vote request.
  • Walkthrough: one method, two statements.
    • public IServiceCollection AddModuleEngagementInfrastructure() (DependencyInjection.cs:19) sits inside the extension(IServiceCollection services) block (:11).
    • services.AddHostedService<LiveChannelPublishProcessor>() (:21) is the only registration. AddHostedService means the host starts it with the application and stops it on shutdown; the processor is a BackgroundService (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30-33) whose ExecuteAsync drains the queue with await foreach (var workItem in queue.Reader.ReadAllAsync(stoppingToken)...) (:41). Because it is registered exactly once there is one reader, which is what preserves per-session FIFO ordering (:11-14); the processor resolves the publisher per item from a fresh scope (:50-51) because the gRPC adapter is registered scoped, and wraps each publish in BestEffort.ExecuteAsync (:45) so a down peer is counted on the besteffort.dispatch.failed meter (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:108) instead of crashing the host, which is the posture the class doc records at LiveChannelPublishProcessor.cs:21-28.
    • return services (:23) for fluent chaining, matching every other registration extension in the repo.
  • Why it's built this way: registering the drain here rather than in the Application layer keeps the hosted-service dependency (and the scope factory, and the publisher resolution) out of the layer that command handlers compile against, which is what lets the Application project stay free of hosting concerns. Keeping the method to a single registration is itself the design statement: this module's infrastructure has no repositories, no adapters, and no clients to register, only entity configurations that are discovered by assembly scan (see AssemblyReference above) and this one worker. ADR-039 is the decision record for the live-channel push path the worker serves.
  • Where it's used: called exactly once, from the module's API-layer composition: services.AddModuleEngagementInfrastructure() at MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:27, the middle of the three-line AddEngagementModule(ApplicationSettings) chain (Application at :26, Infrastructure at :27, API at :28). That method in turn is what EngagementModule.Register calls (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:26-27), so the drain worker starts in any host where the module loader registers Engagement.
  • Caveats / not-in-source: nothing in this file makes the registration conditional, so the drain worker starts even in a host where every Engagement feature flag is off. That is harmless (the channel simply never receives work items), but it is worth noting that the flags in EngagementFeatures gate HTTP routes, not hosted services.

CheckInScopeNames

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInScopeNames.cs:8 · Level 9 · static class

  • What it is: the wire names for CheckInScope, plus the one function that maps an enum value to its name. It is what the AttendeeCheckedIn integration event carries instead of the enum.
  • Depends on: CheckInScope. Externals: none.
  • Concept, strings on the wire, enums in the model. [Rubric §6, CQRS & Event-Driven] assesses how state changes are announced across a boundary, and [Rubric §7, Microservices Readiness] whether that announcement survives independent deployment of producer and consumer. An enum serialized as a number is a version trap: consumers deserialize whatever integer arrives, so adding or reordering a member changes the meaning of already-published messages, and a consumer built before the change reads the new value as a member it thinks it knows. Sending the name makes a new scope purely additive: an old consumer sees an unrecognized string and can log-and-skip, which is exactly what the points handler does (CheckInScopeNames.cs:3-7, and AttendeeCheckedIn.cs:10-13). Note the deliberate asymmetry with persistence: the database stores the numbers (and the filtered unique indexes hard-code them, CheckInConfiguration.cs:22-26) because it is one deployment unit with the code, while the broker carries strings because it is not. Integration events and the outbox are taught in Group 04 and ADR-003.
  • Walkthrough (CheckInScopeNames.cs:8-29): three consts, Event (line 11), Session (14), Sponsor (17), each string equal to its member name. ToName(CheckInScope scope) (lines 22-28) is a switch expression over the three known members with a fallback of scope.ToString() (line 27), so a member added to the enum without a const here still produces its name rather than a number or a throw.
  • Why it's built this way: the mapping is a static class rather than an extension or a converter because both ends need the raw constants, not just the conversion. The producing side calls ToName once, inside the CheckIn factory, so the event is raised in the same transaction as the row and the outbox captures both (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:112-119). The consuming side compares against the constants with StringComparison.Ordinal, never parses back to the enum.
  • Where it's used: CheckIn.Create builds the AttendeeCheckedIn payload with CheckInScopeNames.ToName(checkIn.Scope) (CheckIn.cs:114); AttendeeCheckedInPointsHandler maps each name to an earn rule, Event to an event-check-in award, Session to a session award only when a session id is present, Sponsor to a sponsor award only when a sponsor id is present, and anything else to a warning-logged no-op (.../Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:68-97, warning at :99).
  • Caveats / not-in-source: there is no TryParse counterpart, so nothing in source converts a wire name back to a CheckInScope; consumers branch on the string. Covered by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Shared.Tests/CheckIns/CheckInScopeNamesTests.cs.

BadgePayload

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.Badges · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Badges/BadgePayload.cs:12 · Level 0 · static class

  • What it is: the one definition of what a badge QR code contains, a namespaced prefix plus an opaque credential, shared by the display side that encodes it and the check-in side that decodes it.
  • Depends on: no first-party types. Externals: BCL Guid, CultureInfo.InvariantCulture, and [NotNullWhen(true)] from System.Diagnostics.CodeAnalysis (BadgePayload.cs:1-2).
  • Concept, the shared wire format as code, not convention. [Rubric §9, API & Contract Design] assesses whether a payload contract is stated once and enforced, rather than re-implemented per caller. Two independent surfaces touch this format, the attendee's badge page (which builds the string it renders as a QR) and the organizer's scan path (which parses whatever the camera read), and they live in different layers. Putting the prefix and both directions in one static class means neither can drift: the encoder cannot invent a prefix the decoder does not strip. [Rubric §11, Security] assesses whether untrusted input is validated at the boundary and whether payloads leak more than they must. Both apply here. The payload is deliberately opaque: it carries a credential, never a user id, a name or an email, so a photographed badge tells the photographer nothing, and the server remains the sole authority on which attendee a credential belongs to (BadgePayload.cs:6-11). Scanned text is treated as hostile: parsing is Try-shaped and every ill-formed input returns false rather than throwing (BadgePayload.cs:22-29).
  • Walkthrough:
    • Prefix (line 15) is the const "mmca-adc:badge:". Its job is namespacing: a scanner pointed at a conference lanyard also sees vendor QRs, Wi-Fi codes and session deep links, and the prefix is what tells this one apart.
    • Format(Guid credential) (line 20) concatenates the prefix with the credential in the "D" format under CultureInfo.InvariantCulture, so the encoded text is byte-identical regardless of the device's locale.
    • TryExtractCredential(string? payload, out Guid credential) (lines 30-46) is the decoder and the guard in one. It zeroes the out parameter first (32), rejects null/blank (33-36), trims (38), strips the prefix case-insensitively when present (39-42), and finally requires both a parseable Guid and a non-empty one (44-45). Two details are load-bearing. The prefix is optional on the way in, so an organizer can type a bare credential when a camera fails, and credential != Guid.Empty closes the hole where the literal all-zeros GUID would otherwise parse as a valid badge.
  • Why it's built this way: a badge has to survive a printed lanyard, a phone screenshot and a hand-typed fallback, so the format is plain text with one prefix rather than a signed or encrypted token. The security posture is that the credential itself is the only secret, and the server checks it; the accepted trade-off (a badge image is shareable) is the same one the sponsor deep link documents at RecordSponsorVisitHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:25-32).
  • Where it's used: MyBadge renders BadgePayload.Format(badge.Credential) into its QR (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/MyBadge.razor.cs:51); CheckInScan calls TryExtractCredential to reject unrelated codes before it spends a round trip (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/CheckInScan.razor.cs:128); and CheckInAttendeeHandler calls the same method server-side on the value that actually arrives (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeHandler.cs:43). The client check is a UX shortcut; the server check is the one that counts.
  • Caveats / not-in-source: nothing in the payload is signed or expiring, so replay protection comes entirely from the idempotency rule downstream (see CheckInProcessor) and from the organizer holding the scanner. Covered by MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Shared.Tests/CheckIns/BadgePayloadTests.cs.

MyBadgeDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.Badges · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Badges/MyBadgeDTO.cs:7 · Level 0 · record class

  • What it is: the caller's own badge as the API returns it, a single opaque credential and nothing else.
  • Depends on: no first-party types. Externals: BCL Guid.
  • Concept, minimum disclosure by shape. [Rubric §11, Security] assesses whether a response carries only what the caller needs, and [Rubric §30, Compliance/Privacy/Data Governance] whether personal data is kept out of artifacts that get photographed and shared. A badge QR is printed, screenshotted and held up to strangers' cameras, so the DTO deliberately holds no name, no email and no user id (MyBadgeDTO.cs:3-6). The QR is built client-side from this credential by BadgePayload.Format, so the resolution from credential to attendee happens only on the server. The endpoint reinforces the same rule from the other side: the identity comes from the token, never the request, so no caller can ask for someone else's credential (CheckInsController.cs:42-44).
  • Walkthrough (MyBadgeDTO.cs:7-11): one required init member, Credential (line 10), the Guid minted on the AttendeeBadge aggregate.
  • Why it's built this way: a record with one field looks like ceremony over a bare Guid, but it gives the endpoint a JSON object it can extend (an expiry, a display hint) without breaking clients that already parse the body, which a bare scalar response would not.
  • Where it's used: returned by GetOrCreateMyBadgeHandler (.../GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:74, note the comment at :72-73 making the disclosure rule explicit) via GET my-badge on CheckInsController (.cs:45-48); read client-side by CheckInService (.../UI/Services/CheckInService.cs:37) and rendered by MyBadge.
  • Caveats / not-in-source: the DTO has no expiry field, and the handler's contract is one stable credential per attendee for the life of the badge (GetOrCreateMyBadgeHandler.cs:11-16), so a badge screenshotted yesterday still scans today. Rotation is not modeled anywhere in this type.

RoomCheckInRequest

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.Rooms · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Rooms/RoomCheckInRequest.cs:8 · Level 0 · record class

  • What it is: the body of the attendee's room self check-in, one room id and nothing more.
  • Depends on: the RoomIdentifierType alias. Externals: none.
  • Concept, the anti-abuse property is what the DTO leaves out. [Rubric §11, Security] assesses whether a client can assert facts it should not be trusted with. Two values are conspicuously absent: the attendee (taken from the token) and the session (resolved server-side from the room plus the configured grace window). Because the session is never client-supplied, nobody can claim attendance at a session they are not standing in, and a shared room link records nothing outside that session's window (RecordRoomCheckInHandler.cs:12-16). This is the same "the request carries the least it can" discipline as SponsorVisitRequest; a request DTO's security value is often in the fields it refuses to accept.
  • Walkthrough (RoomCheckInRequest.cs:8-12): one required init member, RoomId (line 11), the room whose printed QR was scanned. RoomCheckInRequestValidator adds the only structural rule, GreaterThan(0) with error code CheckIn.RoomId.Required (.../RecordRoomCheckIn/RoomCheckInRequestValidator.cs:11-15), running in the validating decorator before the handler (see the CQRS decorator pipeline).
  • Why it's built this way: the request doubles as the command type, so the controller hands the deserialized body straight to ICommandHandler<RoomCheckInRequest, Result<RoomCheckInResultDTO>> (CheckInsController.cs:44, :143-147) with no request-to-command translation layer. [Rubric §5, Vertical Slice]: the DTO, its validator and its handler sit in one use-case folder.
  • Where it's used: posted to POST room-visits (CheckInsController.cs:142), which is gated by the RoomCheckIn feature flag (:139) and requires authentication but no organizer permission; built client-side by CheckInService.RecordRoomCheckInAsync (.../UI/Services/CheckInService.cs:65) from the RoomCheckIn page.

RoomCheckInResultDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.Rooms · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/Rooms/RoomCheckInResultDTO.cs:9 · Level 0 · record class

  • What it is: the outcome of a room self check-in, naming back the session the server resolved plus the same repeat flag and timestamp the organizer paths use.
  • Depends on: the SessionIdentifierType alias. Externals: BCL DateTimeOffset, string.
  • Concept, echo back what the server decided. [Rubric §9, API & Contract Design]. Because RoomCheckInRequest deliberately does not name a session, the attendee has no way to know what they were checked into unless the response says so. SessionId plus SessionTitle close that loop in one round trip, which is the same one-round-trip reasoning SponsorVisitResultDTO applies to the sponsor name. The repeat semantics are inherited verbatim from CheckInResultDTO, and the type doc adds the case that only exists here: an organizer may have already scanned this attendee into the same session at the door, so AlreadyCheckedIn covers a repeat by any path (RoomCheckInResultDTO.cs:3-8).
  • Walkthrough (RoomCheckInResultDTO.cs:9-22): four required init members. SessionId (line 12) and SessionTitle (15) come from Conference's room lookup (RoomSessionInfo, consumed at RecordRoomCheckInHandler.cs:66, :92-93). AlreadyCheckedIn (18) is set on the branch that finds an existing session-scoped row for the caller (.cs:87-97), using the same predicate the organizer paths use so a self check-in and a prior door scan collapse onto one row (.cs:78-83). CheckedInOn (21) carries the original time on that branch.
  • Why it's built this way: the flow writes an ordinary Session-scoped CheckIn; only CheckedInByUserId differs, it is the attendee rather than an organizer (RecordRoomCheckInHandler.cs:19-24). Keeping the row identical is what lets the points award, the attendance rollup and the export posture apply unchanged, and it is why this DTO differs from CheckInResultDTO only in the fields the attendee needs to see.
  • Where it's used: returned by RecordRoomCheckInHandler (.cs:90-96, :115-121) through POST room-visits (CheckInsController.cs:144); wrapped client-side in a SelfCheckInOutcome<TResult> by CheckInService (.../UI/Services/CheckInService.cs:62-65) and rendered by the RoomCheckIn page (.../UI/Pages/Rooms/RoomCheckIn.razor.cs:36).
  • Caveats / not-in-source: when no session is running in the room the caller gets a 404 CheckIns.NoCurrentSession instead of this DTO, and an unknown room answers identically on purpose so the response cannot be used to probe which room ids exist (RecordRoomCheckInHandler.cs:58-65, :124-128).

SponsorVisitRequest

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.SponsorVisits · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/SponsorVisits/SponsorVisitRequest.cs:7 · Level 0 · record class

  • What it is: the body of a sponsor booth visit, one sponsor id.
  • Depends on: the SponsorIdentifierType alias. Externals: none.
  • Concept: the same "only the target travels" shape as RoomCheckInRequest. [Rubric §11, Security]: the attendee is always the authenticated caller, so this endpoint can only ever record the caller's own visit, and a client cannot file a visit for someone else (SponsorVisitRequest.cs:3-6, and the endpoint's own statement at CheckInsController.cs:104-112).
  • Walkthrough (SponsorVisitRequest.cs:7-11): one required init member, SponsorId (line 10). SponsorVisitRequestValidator applies GreaterThan(0) with error code CheckIn.SponsorId.Required (.../RecordSponsorVisit/SponsorVisitRequestValidator.cs:11-15).
  • Where it's used: posted to POST sponsor-visits (CheckInsController.cs:117), gated by the SponsorVisits feature flag (:114), authenticated but with no organizer permission; built client-side by CheckInService.RecordSponsorVisitAsync (.../UI/Services/CheckInService.cs:56) from the SponsorVisit landing page, and handled by RecordSponsorVisitHandler.

SponsorVisitResultDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.SponsorVisits · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/SponsorVisits/SponsorVisitResultDTO.cs:12 · Level 0 · record class

  • What it is: the outcome of a sponsor booth visit, the sponsor (id and display name), whether the attendee had already visited, and when.
  • Depends on: the SponsorIdentifierType alias, and mirrors CheckInResultDTO's repeat contract. Externals: BCL DateTimeOffset, string.
  • Concept, denormalize one field to save a round trip. [Rubric §12, Performance & Scalability] assesses whether a surface's data needs are met with the fewest calls, and [Rubric §9, API & Contract Design] how much a response should carry. SponsorName rides back on the DTO precisely so the landing page can render a confirmation card without a follow-up Conference lookup (SponsorVisitResultDTO.cs:7-10). The handler already holds the name from the validation call, so including it costs nothing on the server and removes a whole request from a phone on conference Wi-Fi. The repeat semantics are the same as everywhere else in this family: a second scan is a success carrying AlreadyVisited, not a 409 (.cs:3-6).
  • Walkthrough (SponsorVisitResultDTO.cs:12-25): four required init members, SponsorId (line 15), SponsorName (18), AlreadyVisited (21), VisitedOn (24). RecordSponsorVisitHandler fills the name from SponsorLiveInfo on both branches (.cs:88-94 for the repeat, :114-120 for the new visit), and the repeat branch carries the original CheckedInOn.
  • Why it's built this way: the visit row is an ordinary Sponsor-scoped CheckIn whose CheckedInByUserId equals its UserId, which is what keeps a self-recorded visit distinguishable from an organizer scan on the same table (RecordSponsorVisitHandler.cs:13-17). The uniqueness backstop for two concurrent scans is the filtered unique index on (UserId, SponsorId) WHERE [Scope] = 2 (.cs:75-77, index at CheckInConfiguration.cs:60-62).
  • Where it's used: returned by RecordSponsorVisitHandler through POST sponsor-visits (CheckInsController.cs:119); wrapped in a SelfCheckInOutcome<TResult> by CheckInService (.../UI/Services/CheckInService.cs:53-56) and rendered by the SponsorVisit page (.../UI/Pages/Sponsors/SponsorVisit.razor.cs:37).

UserEngagementBookmarkExportDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Exports · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementBookmarkExportDTO.cs:7 · Level 0 · record (sealed)

  • What it is: one session-bookmark row inside a user's Engagement data export: which session they saved and when.
  • Depends on: the SessionIdentifierType alias (solution-wide global using, see primer §2). It is a member of UserEngagementExportDTO.
  • Concept, ids and dates only. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether a data-subject access path returns the subject's own data and nothing else. This record is the smallest expression of that rule: a bookmark is a personal-schedule entry, so the export carries the session id and the creation date, never the session's title, speaker, or any content owned by somebody else (UserEngagementBookmarkExportDTO.cs:4-5). The concept is taught once on UserEngagementExportDTO; the other export rows follow the same rule.
  • Walkthrough (UserEngagementBookmarkExportDTO.cs:7-14): required SessionIdentifierType SessionId (line 10) and required DateTime CreatedOn (line 13). Both are required, so a projection that forgets a column is a compile error rather than a silently empty export field.
  • Where it's used: projected server-side from UserSessionBookmark by UserEngagementExportService (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:25-28); serialized by UserEngagementExportGrpcService (UserEngagementExportGrpcService.cs:46-50) and rehydrated from the wire by UserEngagementExportServiceGrpcAdapter (UserEngagementExportServiceGrpcAdapter.cs:41-45).

UserEngagementSubmittedQuestionExportDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Exports · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementSubmittedQuestionExportDTO.cs:7 · Level 0 · record (sealed)

  • What it is: one submitted session question inside a user's Engagement data export: the question id, the session it was asked in, and the submission date.
  • Depends on: the SessionQuestionIdentifierType and SessionIdentifierType aliases (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:11). It is a member of UserEngagementExportDTO.
  • Concept, cross-reference: the ids-and-dates rule is the one introduced on UserEngagementBookmarkExportDTO. The extra decision here is that the question text stays out of the summary by design (UserEngagementSubmittedQuestionExportDTO.cs:4-5), even though the subject authored it. [Rubric §30, Compliance, Privacy & Data Governance]: the export is a portable summary of the footprint, not a content dump, and a question's text is visible in the live layer alongside other attendees' questions.
  • Walkthrough (UserEngagementSubmittedQuestionExportDTO.cs:7-17): required SessionQuestionIdentifierType QuestionId (line 10), required SessionIdentifierType SessionId (line 13), required DateTime CreatedOn (line 16).
  • Where it's used: projected from SessionQuestion by UserEngagementExportService (UserEngagementExportService.cs:30-34).

UserEngagementCheckInExportDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Exports · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementCheckInExportDTO.cs:11 · Level 1 · record (sealed)

  • What it is: one check-in record inside a user's Engagement data export: what the attendee was checked in to, under which scope, and when.
  • Depends on: CheckInScope (imported from MMCA.ADC.Engagement.Shared.CheckIns, UserEngagementCheckInExportDTO.cs:1) and the EventIdentifierType / SessionIdentifierType / SponsorIdentifierType aliases.
  • Concept, attendance is not a projection of the ledger. [Rubric §30, Compliance, Privacy & Data Governance] assesses completeness of a data-subject access response. It would be tempting to derive attendance from the points entries, and it would be wrong: PointsAwarder writes nothing when a rule is configured to 0 (PointsAwarder.cs:43-51) and treats a repeat scan as a clean no-op (PointsAwarder.cs:55-64), so a check-in that earned no points still leaves a distinct attendance record the subject is entitled to see. The export therefore reads CheckIn directly, and the reason is written both on this record (UserEngagementCheckInExportDTO.cs:6-9) and at the call site (UserEngagementExportService.cs:48-50).
  • Walkthrough (UserEngagementCheckInExportDTO.cs:11-27): required CheckInScope Scope (line 14), the discriminator; required EventIdentifierType EventId (line 17), always set for every scope; SessionIdentifierType? SessionId (line 20) and SponsorIdentifierType? SponsorId (line 23), each nullable and set only for the matching scope; required DateTimeOffset CheckedInOn (line 26). Note this is the one export row that uses DateTimeOffset rather than DateTime, matching the aggregate's own timestamp (UserEngagementExportService.cs:59).
  • Why it's built this way: one flattened record with nullable scope-specific ids beats three parallel collections, because the export document then reads as a single chronological attendance history regardless of scope, and the reader disambiguates on Scope.
  • Where it's used: projected from CheckIn by UserEngagementExportService (UserEngagementExportService.cs:51-62); carried on UserEngagementExportDTO.CheckIns; mapped across the wire by UserEngagementExportGrpcService (UserEngagementExportGrpcService.cs:66-81) and back by UserEngagementExportServiceGrpcAdapter (UserEngagementExportServiceGrpcAdapter.cs:61-72). The nullable ids are what the two proto3 mapping comments are about: an absent target travels as 0 and is mapped back to null on arrival (UserEngagementExportGrpcService.cs:73-76, UserEngagementExportServiceGrpcAdapter.cs:68-70).

UserEngagementPointsEntryExportDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Exports · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementPointsEntryExportDTO.cs:9 · Level 1 · record (sealed)

  • What it is: one awarded points entry inside a user's Engagement data export: what was earned, what it was earned on, and when.
  • Depends on: PointsActivityType (imported from MMCA.ADC.Engagement.Shared.Points, UserEngagementPointsEntryExportDTO.cs:1) and PointsSubjectKeys for the meaning of SubjectKey.
  • Concept, cross-reference: the export-row rule is the one taught on UserEngagementBookmarkExportDTO. Worth contrasting with its API twin: this record deliberately drops the entry Id that PointsEntryDTO carries (an export is portable data, not a handle the subject can call back with) and reports the audit CreatedOn rather than OccurredOnUtc. [Rubric §30, Compliance, Privacy & Data Governance].
  • Walkthrough (UserEngagementPointsEntryExportDTO.cs:9-22): required PointsActivityType ActivityType (line 12), required int Points (line 15), SubjectKey defaulted to string.Empty (line 18), required DateTime CreatedOn (line 21). SubjectKey is the one non-required member, so a row projected without it still reads as an empty string rather than a null.
  • Where it's used: projected from PointsEntry by UserEngagementExportService (UserEngagementExportService.cs:36-46); carried on UserEngagementExportDTO.PointsEntries; mapped over the wire by UserEngagementExportGrpcService (UserEngagementExportGrpcService.cs:57-65) and back by UserEngagementExportServiceGrpcAdapter (UserEngagementExportServiceGrpcAdapter.cs:52-60).

UserEngagementExportDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Exports · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementExportDTO.cs:9 · Level 2 · record (sealed)

  • What it is: the whole portable export of the personal data the Engagement module holds for one user: bookmarks, submitted questions, the points ledger, check-in history, and leaderboard participation.
  • Depends on: UserEngagementBookmarkExportDTO, UserEngagementSubmittedQuestionExportDTO, UserEngagementPointsEntryExportDTO, UserEngagementCheckInExportDTO.
  • Concept introduced, the per-module export document. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether a real data-subject access and portability path exists rather than a policy promise. Identity owns the aggregated user export document but owns none of Engagement's data, so each module publishes its own export shape and its own read contract (IUserEngagementExportService); Identity's EngagementUserDataExportSection stitches this document into the whole (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/EngagementUserDataExportSection.cs:30-45). [Rubric §7, Microservices Readiness]: because the document lives in *.Shared and the contract is an interface, the aggregation works identically whether Engagement is in-process or a separate service.
  • Concept, "active footprint" is the export's definition of current. Every collection is read through the EF global soft-delete filter, so soft-deleted rows are excluded (UserEngagementExportService.cs:14-15). The visible consequence is stated on the service: an opt-in the user has since left reports as not on the leaderboard rather than as a historical membership. That is the erasure model of ADR-005 showing through on the access path.
  • Walkthrough (UserEngagementExportDTO.cs:9-31): Bookmarks (line 12), SubmittedQuestions (line 15), PointsEntries (line 18), all IReadOnlyList<...> defaulted to []; CheckIns (line 24), carried separately from the ledger for the reason given on UserEngagementCheckInExportDTO (UserEngagementExportDTO.cs:20-23); IsOnLeaderboard (line 27) and LeaderboardDisplayName (line 30), the latter null when the user never opted in. Every collection defaulting to [] is what makes an empty document (the disabled-module stub, or a user with no Engagement footprint) safe to enumerate.
  • Why it's built this way: ids plus dates keep the document cheap to build and cheap to ship across a service boundary, and the "no content authored by other users" rule (UserEngagementExportDTO.cs:4-8) means the export can be handed to the subject without a redaction pass. The nullable LeaderboardDisplayName is a documented contract the gRPC adapter honors explicitly: proto3 scalars carry no null, so the server emits the empty string and the adapter maps it back to null (UserEngagementExportServiceGrpcAdapter.cs:75-80).
  • Where it's used: built by UserEngagementExportService (UserEngagementExportService.cs:73-81); returned by IUserEngagementExportService; serialized by UserEngagementExportGrpcService and rebuilt by UserEngagementExportServiceGrpcAdapter (UserEngagementExportServiceGrpcAdapter.cs:39-81); consumed by Identity's ExportUserDataHandler through its Engagement section.

IUserEngagementExportService

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Exports · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/IUserEngagementExportService.cs:14 · Level 3 · interface

  • What it is: the cross-module contract for reading one user's Engagement-owned personal data. One method, one document out.
  • Depends on: UserEngagementExportDTO, the UserIdentifierType alias, and ServiceContractAttribute from MMCA.Common.Shared.Abstractions (IUserEngagementExportService.cs:1).
  • Concept, the extraction-ready cross-module interface. [Rubric §7, Microservices Readiness] assesses whether modules talk through explicit contracts that survive being pulled apart, and [Rubric §3, Clean Architecture] assesses dependency direction. The interface lives in *.Shared, the only Engagement assembly a foreign module is allowed to reference, so Identity's export handler depends on a contract and never on Engagement's domain entities or DbContext. The doc comment names the pattern explicitly (IUserEngagementExportService.cs:5-12): it is the same shape as IBookmarkCountService, an in-process implementation inside the Engagement service and a gRPC adapter in MMCA.ADC.Engagement.Contracts everywhere else (ADR-007, ADR-008).
  • Concept introduced in this chapter, [ServiceContract] as an enforced marker. The interface carries [ServiceContract] (IUserEngagementExportService.cs:13), the same marker IBookmarkCountService carries (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:10). It is not documentation: an architecture fitness rule scans every mapped assembly for types wearing it and fails the build if such a type depends on the producing service's Domain, Application, or Infrastructure (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:3-12). [Rubric §34, Architecture Governance & Documentation] assesses whether an architectural rule is asserted or merely written down; this one is a test (ADR-015). The attribute also carries an optional Version defaulting to "v1" (ServiceContractAttribute.cs:34-37), which this interface leaves at the default.
  • Walkthrough (IUserEngagementExportService.cs:14-24): one member, Task<UserEngagementExportDTO> GetUserEngagementExportAsync(UserIdentifierType userId, CancellationToken cancellationToken) (line 23). Two details are deliberate. The CancellationToken has no default, so every caller passes one through explicitly. The return is a bare Task<T> rather than a Result<T>: there is no expected business failure here (an unknown user simply yields an empty document), so a transport fault is an exception, which is exactly what lets the aggregating handler degrade that one section instead of failing the whole export (EngagementUserDataExportSection.cs:12-16).
  • Why it's built this way: a single coarse method (rather than one per collection) keeps the wire contract to one round trip and one proto message, and keeps the interface stable as the export grows: the points ledger, check-ins, and leaderboard participation were all added to UserEngagementExportDTO without touching this signature.
  • Where it's used: implemented in-process by UserEngagementExportService, stubbed by DisabledUserEngagementExportService, served over the wire by UserEngagementExportGrpcService, and satisfied remotely by UserEngagementExportServiceGrpcAdapter. The consumer is Identity's EngagementUserDataExportSection (EngagementUserDataExportSection.cs:19), behind ExportUserDataHandler.
  • Caveats / not-in-source: the method-level doc says "session bookmarks and submitted session questions" (IUserEngagementExportService.cs:17-19), which is narrower than what the contract now returns; the type-level summary (:5-12) and the implementation are current. The narrower phrasing is stale doc text, not a behavior difference.

DisabledUserEngagementExportService

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Exports · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/DisabledUserEngagementExportService.cs:7 · Level 4 · class (sealed, internal)

  • What it is: the stand-in implementation of IUserEngagementExportService registered when the Engagement module is switched off in a host. It returns an empty export document.
  • Depends on: IUserEngagementExportService, UserEngagementExportDTO.
  • Concept introduced, the disabled-module stub (Null Object). [Rubric §2, Design Patterns] assesses deliberate use of known patterns; this is the Null Object, an implementation that satisfies the contract while doing nothing, so consumers never branch on "is the module present". [Rubric §7, Microservices Readiness] and [Rubric §15, Best Practices & Code Quality]: the module system lets each host enable a subset of modules, and a host that runs Identity without Engagement must still be able to resolve IUserEngagementExportService. EngagementModule registers this stub from its RegisterDisabledStubs hook (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:30-34), alongside DisabledBookmarkCountService for the other cross-module contract. In the extracted topology the stub is then displaced: the Contracts DI facade uses Replace (not TryAdd) so the gRPC adapter wins over whichever binding is present, and the inline comment says exactly that (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:76-85).
  • Walkthrough (DisabledUserEngagementExportService.cs:7-12): one method (line 10), => Task.FromResult(new UserEngagementExportDTO()). No async state machine, no I/O, and no null: because every collection on UserEngagementExportDTO defaults to [], the caller enumerates an empty export rather than guarding for null. Both parameters are ignored by design. The class is internal, so nothing outside the Shared assembly can take a dependency on the stub itself; consumers only ever see the interface.
  • Why it's built this way: returning an empty document rather than throwing means Identity's export still succeeds in a host where Engagement is absent, and the subject gets a document with no Engagement content instead of a failed request. The registration is a singleton (EngagementModule.cs:33), which is safe precisely because the type is stateless.
  • Where it's used: registered by EngagementModule.RegisterDisabledStubs (EngagementModule.cs:33); replaced by UserEngagementExportServiceGrpcAdapter through the AddEngagementUserExportClient helper, which registers the adapter as scoped (DependencyInjection.cs:76-85).

LeaderboardEntryDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/LeaderboardEntryDTO.cs:7 · Level 0 · record (sealed)

  • What it is: one row of the public points leaderboard: a rank, a display name, and a total. It is the entire wire shape of GET /api/points/leaderboard.
  • Depends on: nothing first-party. Three primitives only (int, string, int).
  • Concept, the opt-in projection as a privacy boundary. [Rubric §11, Security] assesses whether a surface exposes only what the actor consented to expose, and [Rubric §30, Compliance, Privacy & Data Governance] assesses whether personal data flows are deliberate. The board is the one Engagement surface that shows one attendee's data to another attendee, and this record is what makes that safe: it carries no user id, no email, and no name from Identity. DisplayName (LeaderboardEntryDTO.cs:13) is the snapshot the attendee published when they opted in, and GetLeaderboardHandler builds the board purely from the LeaderboardOptIn rows plus the ledger, making no call into Identity (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:12-17). An attendee who never opted in is absent rather than present under a name they did not choose.
  • Walkthrough (LeaderboardEntryDTO.cs:7-17): Rank (line 10), an int starting at 1; DisplayName (line 13), defaulted to string.Empty so the record is never constructed into a null name; Points (line 16). All three are init-only, so a board row is immutable once the handler has built it.
  • Why it's built this way: Rank is carried on the wire rather than inferred from list position because the handler assigns distinct sequential ranks (1, 2, 3) rather than competition ranking (1, 1, 3), with the published name compared ordinally as the tie-break (GetLeaderboardHandler.cs:18-24, :65-77). That decision belongs to the server, so the client renders the number it is given instead of recomputing a different one.
  • Where it's used: returned by PointsController.GetLeaderboardAsync (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:78), produced by GetLeaderboardHandler (GetLeaderboardHandler.cs:71-76), and consumed client-side by PointsService (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Points/PointsService.cs:43-56) for the MyPoints page.

PointsActivityType

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsActivityType.cs:18 · Level 0 · enum

  • What it is: the closed set of things an attendee can do to earn points. Six values, each an earn rule.
  • Depends on: nothing. It is consumed by PointsEntry, PointsSettings, and every points DTO in this chapter.
  • Concept, the enum whose numbers are a persisted contract. [Rubric §8, Data Architecture] assesses whether stored values carry deliberate, stable semantics. This enum is not a UI label set: the numeric value is written to the ActivityType column of every ledger row and is one third of the unique index (UserId, ActivityType, SubjectKey) that makes an award idempotent (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/Points/PointsEntryConfiguration.cs:45-48). Renumbering a member would silently re-point historical rows at a different rule, which is exactly what the type documentation forbids (PointsActivityType.cs:4-5). [Rubric §15, Best Practices & Code Quality]: the constraint is written into the type rather than left as tribal knowledge.
  • Concept, reserving 0 instead of defining it. Numbering starts at 1 on purpose so that a defaulted column or a payload that never set the field cannot read as a real earn rule (PointsActivityType.cs:11-15). That is a deliberate violation of the analyzer rule "enums should have zero value", so the file carries a targeted SuppressMessage for CA1008 whose Justification states the reason inline (PointsActivityType.cs:17). [Rubric §15, Best Practices & Code Quality] assesses whether analyzer deviations are justified in place rather than blanket-disabled; this is the pattern to copy. The invariant that rejects the unset value is PointsEntryInvariants.EnsureActivityTypeIsDefined, which calls Enum.IsDefined (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntryInvariants.cs:52-55).
  • Walkthrough (PointsActivityType.cs:20-36): EventCheckIn = 1 (line 21), the optional info-desk activation scan; SessionCheckIn = 2 (line 24), the primary conference-day earn mechanic; SessionFeedback = 3 (line 27); EventFeedback = 4 (line 30); QuestionAsked = 5 (line 33), awarded once per session rather than once per question; SponsorVisit = 6 (line 36), awarded once per sponsor from the sponsor's printed QR.
  • Why it's built this way: the doc remarks record a domain fact the numbering alone would not convey (PointsActivityType.cs:6-10): the conference runs door and arrival check-in through TicketLeap, so EventCheckIn is an optional activation scan and not a door process, which is why SessionCheckIn is the mechanic the economy is tuned around (see the values in PointsSettings).
  • Where it's used: stored on PointsEntry; switched over by PointsSettings.GetPointsFor; passed to IPointsAwarder.AwardAsync by every points handler (AttendeeCheckedInPointsHandler, SessionQuestionSubmittedPointsHandler, SessionFeedbackSubmittedPointsHandler, EventFeedbackSubmittedPointsHandler); carried on PointsEntryDTO, PointsActivityTotalDTO, and UserEngagementPointsEntryExportDTO. It also crosses the gRPC boundary as a bare int in both directions, and both sides carry a comment saying the numeric stability is what makes the cast lossless (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/UserEngagementExportGrpcService.cs:59-61, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:54-56).

SetLeaderboardParticipationRequest

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/SetLeaderboardParticipationRequest.cs:8 · Level 0 · record (sealed)

  • What it is: the request body for joining or leaving the public leaderboard. One boolean, nothing else.
  • Depends on: nothing first-party.
  • Concept, the request shape as the security control. [Rubric §11, Security] assesses whether a surface can be misused by a caller who controls the payload, and [Rubric §9, API & Contract Design] assesses contracts that make the wrong call impossible to express. The published display name is resolved from the caller's token principal server-side by SetLeaderboardParticipationHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/SetLeaderboardParticipation/SetLeaderboardParticipationHandler.cs:65) and is deliberately absent from this record (SetLeaderboardParticipationRequest.cs:4-6). A DisplayName property here would let any caller publish a name they do not own on the one surface whose purpose is showing names to other attendees. The endpoint likewise takes no user id, so there is no ownership argument to check and none to get wrong (PointsController.cs:90-98).
  • Walkthrough (SetLeaderboardParticipationRequest.cs:8-12): a single bool Participate { get; init; } (line 11). true joins, false leaves, and the handler branches on exactly that (SetLeaderboardParticipationHandler.cs:51-53).
  • Why it's built this way: making it a record rather than a bare bool parameter leaves room for the request to grow (a future opt-in preference) without changing the route shape, and it matches the ICommandHandler<TCommand, TResult> signature the controller dispatches to (PointsController.cs:39).
  • Where it's used: bound by PointsController.SetLeaderboardParticipationAsync from the body of PUT /api/points/me/leaderboard-participation (PointsController.cs:99-112); constructed client-side by PointsService (PointsService.cs:67); handled by SetLeaderboardParticipationHandler.

CreateBookmarkRequest

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.UserSessionBookmarks · MMCA.ADC.Engagement.Shared/UserSessionBookmarks/CreateBookmarkRequest.cs:6 · Level 0 · record

  • What it is: the two-field request body for "bookmark this session for this user". It is simultaneously the JSON contract of the POST /bookmarks endpoint and the CQRS command object the handler consumes; Engagement declares no separate internal command type for the create use case.
  • Depends on: no first-party types. It uses the two module-scoped identifier aliases UserIdentifierType and SessionIdentifierType (the alias convention is taught in the primer), which is why the type sits at Level 0.
  • Concept introduced, request-as-command. [Rubric §9, API & Contract Design] assesses whether the wire contract is explicit and stable; [Rubric §6, CQRS & Event-Driven] assesses whether writes flow through a command object and a single handler. Here the two collapse into one type on purpose: BookmarksController injects ICommandHandler<CreateBookmarkRequest, Result<UserSessionBookmarkDTO>> (MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:35) and passes the deserialized body straight into createHandler.HandleAsync(request, cancellationToken) (BookmarksController.cs:72), so there is no request-to-command mapping step to keep in sync. The trade-off is that the public contract and the command shape can no longer diverge, which is exactly what the module wants for a request this small.
  • Walkthrough: two members, both required and init-only. UserId (CreateBookmarkRequest.cs:9) names the owner of the bookmark; SessionId (CreateBookmarkRequest.cs:12) names the session being saved. required means neither construction nor JSON deserialization can leave a member unset, and init freezes the instance after construction, so no decorator in the CQRS pipeline can mutate a command mid-flight.
  • Why it's built this way: keeping the payload to two identifiers pushes every other concern to a dedicated place: shape validation to CreateBookmarkRequestValidator (MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkRequestValidator.cs:9), business rules (session validity, duplicate detection) to CreateBookmarkHandler (.../Create/CreateBookmarkHandler.cs:23), and authorization to the controller. Because UserId travels in the body rather than being inferred server-side, the controller binds it to the caller: a non-Organizer whose user-identifier claim does not equal request.UserId gets a Forbidden failure (BookmarksController.cs:58-70), the create-side counterpart of the OwnerOrAdminFilter on the list endpoints (ADR-033). [Rubric §11, Security]: without that check any authenticated user could create bookmarks owned by someone else. Note also the [Idempotent] attribute on the action (BookmarksController.cs:50), justified in the action's own doc comment (:45-47): because the body names the (user, session) pair explicitly and a duplicate already answers 409, replaying a retried star is what the attendee meant.
  • Where it's used: the API entry point CreateAsync (BookmarksController.cs:54-55); the UI contract IBookmarkUIService (MMCA.ADC.Engagement.UI/Services/Bookmarks/IBookmarkUIService.cs:15); and SessionBookmarkUIService, which constructs one inline when a star is toggled on a session list (MMCA.ADC.Engagement.UI/Services/Bookmarks/SessionBookmarkUIService.cs:69).

IBookmarkCountService

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.UserSessionBookmarks · MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:11 · Level 0 · interface

  • What it is: the cross-module read contract that lets Conference ask "how many people bookmarked this session?" without referencing an Engagement domain entity, an Engagement DbContext, or an Engagement package beyond .Shared.
  • Depends on: nothing first-party beyond the SessionIdentifierType alias and the ServiceContractAttribute marker it carries (IBookmarkCountService.cs:1, :10); BCL Task, IReadOnlyDictionary, IReadOnlyCollection, CancellationToken.
  • Concept introduced, the extraction-ready cross-module interface. [Rubric §7, Microservices Readiness] assesses whether modules talk through contracts that survive a process split; [Rubric §1, SOLID] (Dependency Inversion) assesses whether the consumer depends on an abstraction rather than a concrete collaborator. This interface is the canonical example in ADC: it has three different implementations that a host picks between with no consumer change at all. In-process it is BookmarkCountService (registered with TryAddScoped at MMCA.ADC.Engagement.Application/DependencyInjection.cs:46); when Engagement is switched off in a host it is DisabledBookmarkCountService (MMCA.ADC.Engagement.API/EngagementModule.cs:32); and when Engagement runs as its own process it is BookmarkCountServiceGrpcAdapter, swapped in with Replace by AddEngagementBookmarkCountClient() (MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:49, called from MMCA.ADC.Conference.Service/Program.cs:350). That is ADR-007 and ADR-008 made concrete: the C# call site never learns which one it got. The [ServiceContract] marker is what declares this intent in code rather than in a comment.
  • Walkthrough: two methods, both taking an explicit (non-defaulted) CancellationToken so every implementation, including the gRPC one, threads cancellation. GetBookmarkCountForSessionAsync(sessionId, cancellationToken) (IBookmarkCountService.cs:19) returns a bare int for one session. GetBookmarkCountsForSessionsAsync(sessionIds, cancellationToken) (IBookmarkCountService.cs:28-30) is the batched form, and its contract is spelled out in the doc comment (IBookmarkCountService.cs:22-23): every requested session id is present in the result, and a session with no bookmarks maps to 0. That sentence is a real contract, not decoration: it is what forces the disabled stub to project zeros rather than return an empty map. [Rubric §12, Performance & Scalability]: the batch method exists so the speaker dashboard resolves N sessions in one round trip instead of N, which matters most when the call is a network hop rather than a local query.
  • Why it's built this way: returning plain int / IReadOnlyDictionary rather than Result<T> keeps the contract trivially projectable onto a proto message; a fault surfaces as an exception (over gRPC, a status the caller's resilience pipeline can react to) instead of a failure payload each caller must unwrap. Living in .Shared is what keeps the reference direction legal: Conference references Engagement's contract assembly only, which the Conference service project spells out in a comment on its own ProjectReference (MMCA.ADC.Conference.Service/MMCA.ADC.Conference.Service.csproj:26).
  • Where it's used: Conference's GetSessionBookmarkCountHandler and the batched GetSessionBookmarkCountsHandler; on the producing side it is what BookmarkCountsGrpcService publishes over the wire.

PointsActivityTotalDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsActivityTotalDTO.cs:7 · Level 1 · record (sealed)

  • What it is: the rollup of one earn rule across every attendee: how much it paid out and how many times it fired.
  • Depends on: PointsActivityType. It is a member of PointsOverviewDTO.
  • Concept, the two-number rollup. [Rubric §9, API & Contract Design] assesses whether a read model answers the question its consumer actually asks. Total points alone would not tell an organizer whether a mechanic was popular or merely expensive, so the row carries both the payout and the count: SessionCheckIn paying 4,000 across 400 awards and EventCheckIn paying 4,000 across 160 awards are different facts about the room. [Rubric §30, Compliance, Privacy & Data Governance]: the rollup is aggregate by construction, so the organizer view carries no attendee identity.
  • Walkthrough (PointsActivityTotalDTO.cs:7-17): ActivityType (line 10), TotalPoints (line 13), AwardCount (line 16), all init-only.
  • Where it's used: built by GetPointsOverviewHandler by grouping the projected ledger rows by activity and ordering by the enum value (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetPointsOverview/GetPointsOverviewHandler.cs:52-63), carried on PointsOverviewDTO.PerActivity, rendered by OrganizerPointsOverview.

PointsEntryDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsEntryDTO.cs:7 · Level 1 · record (sealed)

  • What it is: one awarded entry from the points ledger, as seen on the wire: id, activity, points, subject, and when it happened.
  • Depends on: PointsActivityType, PointsSubjectKeys (for the meaning of SubjectKey), and the PointsEntryIdentifierType alias (int, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/MMCA.ADC.Engagement.GlobalUsings.IdentifierType.cs:10).
  • Concept, the append-only ledger row and its snapshot value. [Rubric §8, Data Architecture] assesses whether history is preserved rather than recomputed. Points is what the rules said at award time, not what the current configuration would pay (PointsEntryDTO.cs:4-5, and the same statement on the writer, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:12-17). Retuning the economy mid-conference therefore changes the next award and never rewrites an attendee's history, which is what makes a total explainable.
  • Concept, the DTO that deliberately omits the owner. [Rubric §30, Compliance, Privacy & Data Governance] and [Rubric §11, Security]. There is no UserId on this record, and that absence is load-bearing: the same type serves the caller's own ledger (where the owner is the caller by construction) and the organizer's recent-activity tail (where the owner must not be disclosed). GetPointsOverviewHandler projects a private OverviewRow that does carry the user id, uses it only for the distinct participant count, and drops it before anything leaves the handler (GetPointsOverviewHandler.cs:92-108, :73-80, :85).
  • Walkthrough (PointsEntryDTO.cs:7-23): Id (line 10); ActivityType (line 13); Points (line 16), the snapshot; SubjectKey (line 19), defaulted to string.Empty, holding the event: / session: / sponsor: key built by PointsSubjectKeys; OccurredOnUtc (line 22), a UTC DateTime.
  • Why it's built this way: OccurredOnUtc is separate from the row's audit CreatedOn because an award can be written by a handler reacting to an event that happened earlier; the ledger sorts on the earn instant, with the id as the tie-break so awards stamped in the same instant keep a stable order across pages (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetMyPoints/GetMyPointsHandler.cs:79-88).
  • Where it's used: MyPointsDTO.Entries via GetMyPointsHandler (GetMyPointsHandler.cs:55-66) and PointsOverviewDTO.RecentEntries via GetPointsOverviewHandler (GetPointsOverviewHandler.cs:67-81).

PointsSubjectKeys

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSubjectKeys.cs:11 · Level 1 · class (static)

  • What it is: the one place that formats the subject key scoping an award to the thing it was earned on. Three factories (event:, session:, sponsor:) plus the column-length constant they must fit.
  • Depends on: NotificationScopeKey from MMCA.Common.Shared.Notifications (PointsSubjectKeys.cs:2), and the module identifier aliases EventIdentifierType, SessionIdentifierType, SponsorIdentifierType (solution-wide global using, see primer §2). Externals: System.Globalization and string.Create (PointsSubjectKeys.cs:1, :32).
  • Concept introduced, the idempotency key built in one place. [Rubric §8, Data Architecture] assesses whether the schema's guarantees have a single owner in code, and [Rubric §1, SOLID] assesses single responsibility. An award is idempotent because of the unique index on (UserId, ActivityType, SubjectKey) (PointsEntryConfiguration.cs:45-48). That index only works if every producer of a subject key formats it identically: session:42 and Session: 42 are different rows and therefore two awards for one attendance. This class exists so no call site ever hand-formats the string (PointsSubjectKeys.cs:7-9): one shape, one culture, one place to change it. [Rubric §15, Best Practices & Code Quality].
  • Concept, the format is shared with the notification scope key rather than re-derived. The event- and session-scoped keys are not formatted here at all: ForEvent and ForSession delegate to NotificationScopeKey.ForEvent / NotificationScopeKey.ForSession in MMCA.Common (PointsSubjectKeys.cs:20, :26). That is deliberate, because the two subsystems already agreed on the same string. The framework type owns the format, the event/session prefixes, and the regex that guards it, ^(event|session):[0-9]+$, which the notification hub enforces before a client may join a live group (MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:20-44, :32). Delegating means a points key and a live-channel key for the same session are byte-identical and cannot drift apart. [Rubric §15, Best Practices & Code Quality] and [Rubric §6, CQRS & Event-Driven Design]: the shared format lives in the framework, and the ADC-specific extension of it (the sponsor scope, which the notification pattern does not know about) stays in the module.
  • Walkthrough
    • MaxLength = 64 (PointsSubjectKeys.cs:14): the ceiling the column accepts. Both halves of the system read this same constant, the EF configuration through HasMaxLength(PointsSubjectKeys.MaxLength) (PointsEntryConfiguration.cs:38-40) and the domain through PointsEntryInvariants.EnsureSubjectKeyIsValid, which range-checks 1 to MaxLength through CommonInvariants.EnsureStringLengthIsWithin (PointsEntryInvariants.cs:38-46), so a truncating schema and a permissive validator cannot drift apart.
    • ForEvent(eventId) (PointsSubjectKeys.cs:19-20) and ForSession(sessionId) (:25-26): thin forwards to NotificationScopeKey, so the event:{id} and session:{id} shapes have exactly one definition in the codebase.
    • ForSponsor(sponsorId) (PointsSubjectKeys.cs:31-32): the one key formatted locally, string.Create(CultureInfo.InvariantCulture, $"sponsor:{sponsorId}"). There is no sponsor scope in the notification pattern, so this scope is Engagement's own. string.Create with an explicit CultureInfo.InvariantCulture is the deliberate choice over plain interpolation: an id formatted under a culture with non-ASCII digits or different separators would produce a key that never matches the one written yesterday. The framework methods it forwards to make the same choice for the same reason (NotificationScopeKey.cs:16-18).
  • Why it's built this way: the subject key is the half of the idempotency rule that varies per activity, so it has to be data rather than a column. Encoding the scope as a prefix (event:, session:, sponsor:) keeps one column serving three scopes without a discriminator column, and keeps the key human-readable in a support query.
  • Where it's used: AttendeeCheckedInPointsHandler picks the scope per check-in (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:73, :81, :92); SessionFeedbackSubmittedPointsHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:42), EventFeedbackSubmittedPointsHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:40), and SessionQuestionSubmittedPointsHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:85) each build theirs the same way. The value ends up on PointsEntryDTO and UserEngagementPointsEntryExportDTO.

DisabledBookmarkCountService

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.UserSessionBookmarks · MMCA.ADC.Engagement.Shared/UserSessionBookmarks/DisabledBookmarkCountService.cs:7 · Level 1 · class (internal sealed)

  • What it is: the null-object implementation of IBookmarkCountService that a host registers when the Engagement module is switched off. Every count answers zero.
  • Depends on: IBookmarkCountService only.
  • Concept introduced, the disabled-module stub. [Rubric §2, Design Patterns] (Null Object) and [Rubric §7, Microservices Readiness] both apply. The module system lets a host boot a subset of modules; a module that is off must still satisfy the interfaces other modules resolve, or the container throws at startup and the whole host fails on a feature nobody asked for. So IModule exposes RegisterDisabledStubs, and Engagement's implementation registers this class plus the data-subject export stub (MMCA.ADC.Engagement.API/EngagementModule.cs:30-34). [Rubric §29, Resilience & Business Continuity]: the degradation is graceful and legible, a session simply shows zero saves instead of erroring.
  • Walkthrough: GetBookmarkCountForSessionAsync returns Task.FromResult(0) with no query allocated at all (DisabledBookmarkCountService.cs:10-11). GetBookmarkCountsForSessionsAsync honours the batch contract the interface documents rather than returning an empty map: it projects the requested ids into a dictionary of zeros, (sessionIds ?? []).Distinct().ToDictionary(id => id, _ => 0) (DisabledBookmarkCountService.cs:21-22). Two guards are packed into that one line and the <remarks> above it explains the second (:14-17): ?? [] means a caller passing null still gets a usable empty result instead of a NullReferenceException, and Distinct() mirrors the real BookmarkCountService so a duplicate id collapses instead of throwing out of ToDictionary. Note there is no async here at all: both methods are synchronous returns wrapped in a completed task.
  • Why it's built this way: keeping the stub in .Shared beside the interface means any host can register it without referencing Engagement's Application layer, which is precisely the situation in the Conference service, where Engagement's real implementation is not even loaded. The comment in MMCA.ADC.Conference.Service/Program.cs:33-38 records the intended flow, and the pipeline comment at :326-332 states the ordering rule: the module registration runs first, then AddEngagementBookmarkCountClient() Replaces whatever is there with the gRPC adapter.
  • Where it's used: EngagementModule.RegisterDisabledStubs (MMCA.ADC.Engagement.API/EngagementModule.cs:32), called by ModuleLoader for a disabled module; it is the fallback the Conference service starts from before the gRPC client registration overwrites it.

UserSessionBookmarkDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.UserSessionBookmarks · MMCA.ADC.Engagement.Shared/UserSessionBookmarks/UserSessionBookmarkDTO.cs:8 · Level 1 · record

  • What it is: the read contract for one personal-schedule entry: which user saved which session, when, and under what bookmark id.
  • Depends on: IBaseDTO<TIdentifierType> from MMCA.Common.Shared.DTOs (UserSessionBookmarkDTO.cs:1, :8), and the UserSessionBookmarkIdentifierType, UserIdentifierType, SessionIdentifierType aliases.
  • Concept, the DTO as the module's outward-facing projection. [Rubric §3, Clean Architecture] assesses whether the domain entity leaks past the application boundary: it does not, UserSessionBookmark never crosses the wire and UserSessionBookmarkDTOMapper does the projection. [Rubric §9, API & Contract Design]: implementing IBaseDTO<T> is what lets this record flow through the framework's generic read plumbing, for example the IEntityQueryService<UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkIdentifierType> the controller injects (MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:39), with no bookmark-specific query code.
  • Walkthrough: Id satisfies the IBaseDTO<T> contract (UserSessionBookmarkDTO.cs:11); UserId and SessionId are the two scalar cross-module references (:14, :17), scalar rather than navigation properties because the referenced rows live in other services' databases; CreatedOn (:20) is the audit timestamp. The first three are required, CreatedOn is not, which is the readable signal that it is server-stamped rather than caller-supplied. There is no IsDeleted here: soft-deleted bookmarks are filtered out by the global query filter long before projection.
  • Why it's built this way: a flat, four-member record keeps the payload cheap for a list endpoint that a phone pages through on a conference floor, and keeps the shape identical whether it is produced in-process or deserialized from HTTP in the UI.
  • Where it's used: returned by CreateBookmarkHandler and GetUserBookmarksHandler inside a Result<T>; declared on both controller actions (BookmarksController.cs:34, :35, :51); deserialized client-side by BookmarkService and by SessionBookmarkUIService (MMCA.ADC.Engagement.UI/Services/Bookmarks/SessionBookmarkUIService.cs:71).

MyPointsDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/MyPointsDTO.cs:7 · Level 2 · record (sealed)

  • What it is: the caller's own points view: the running total, their leaderboard status and published name, and a page of the entries behind the total.
  • Depends on: PointsEntryDTO.
  • Concept, the always-caller-scoped read model. [Rubric §11, Security] assesses whether a read can be widened by a hostile caller. This DTO has no user id on it, and neither does the route that returns it: GetMyPointsHandler resolves the attendee through ICurrentUserService.RequireUserId("Points.Forbidden") and returns that failure verbatim when the token carries no user id (GetMyPointsHandler.cs:41-45). The paging arguments select how much of the caller's own ledger comes back, never whose ledger it is (PointsController.cs:44-48), and they are clamped through PagingMath.Clamp against a MaxPageSize of 100 rather than trusted (GetMyPointsHandler.cs:31-32, :48-51).
  • Concept, total summed on read rather than materialized. [Rubric §12, Performance & Scalability] assesses whether a read cost is proportionate to the workload. Total is the sum of every entry the caller owns, computed per request (GetMyPointsHandler.cs:90-94), not a counter maintained on a user row. The handler documents the trade explicitly (GetMyPointsHandler.cs:16-23): at conference scale (about 100 attendees, a few dozen entries each) the summed read is trivial and it removes the class of bug where a materialized total drifts from its ledger. The named scale lever, if the population ever grows, is a materialized total updated by the awarder, and it would change only that handler and PointsAwarder.
  • Walkthrough (MyPointsDTO.cs:7-20): Total (line 10), summed over every entry and not just the returned page; IsOnLeaderboard (line 13); LeaderboardDisplayName (line 16), nullable, null when the caller is not on the board; Entries (line 19), an IReadOnlyList<PointsEntryDTO> defaulted to [] and returned newest first.
  • Why it's built this way: carrying the leaderboard status on the same document the points page already fetches means the UI renders the join/leave toggle without a second round trip; the opt-in read relies on the soft-delete query filter, so an attendee who left the board correctly reports as not on it (GetMyPointsHandler.cs:68-77). IsOnLeaderboard is derived from displayName is not null rather than carried as its own column, so the flag and the name can never disagree (GetMyPointsHandler.cs:95-96).
  • Where it's used: returned by PointsController.GetMyPointsAsync from GET /api/points/me (PointsController.cs:49-65); fetched client-side by IPointsUIService / PointsService (PointsService.cs:22-40) for the MyPoints page.

PointsOverviewDTO

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsOverviewDTO.cs:12 · Level 2 · record (sealed)

  • What it is: the organizer rollup of the points game: how many attendees are playing, what has been paid out, the per-activity breakdown, and a tail of recent activity.
  • Depends on: PointsActivityTotalDTO, PointsEntryDTO.
  • Concept, the cross-attendee read that carries no attendee. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether a privileged view exposes more personal data than its purpose needs. This is the only points surface that reads across every attendee, and it is built so there is nothing to leak: RecentEntries carries entry ids, activity, points, and timestamps only (PointsOverviewDTO.cs:6-10), so an organizer sees THAT points were earned and for what, never by whom. Who earned what stays with the attendee and with the leaderboard names those attendees chose to publish. [Rubric §11, Security]: the endpoint is additionally gated by [HasPermission(EngagementPermissions.PointsViewOverview)] on top of the controller-level [Authorize] (PointsController.cs:35, :120-121), the permission model of ADR-020.
  • Walkthrough (PointsOverviewDTO.cs:12-25): ParticipantCount (line 15), distinct attendees with at least one entry; TotalPointsAwarded (line 18); PerActivity (line 21), the PointsActivityTotalDTO breakdown, defaulted to []; RecentEntries (line 24), newest first, defaulted to [].
  • Why it's built this way: GetPointsOverviewHandler reads the ledger once and folds every figure in memory rather than issuing a query per number (GetPointsOverviewHandler.cs:17-22, :41-50): the Application layer has no EF Core reference, so a SQL GROUP BY is not available to it, and at conference scale one projected pass is cheaper than the round trips a per-figure query set would cost. The requested tail length is clamped with Math.Clamp(query.RecentCount, 1, MaxRecentCount) against a ceiling of 100 (GetPointsOverviewHandler.cs:28-29, :38). [Rubric §12, Performance & Scalability]: the trade is documented along with the materialized-rollup lever that would replace it.
  • Where it's used: returned by PointsController.GetPointsOverviewAsync from GET /api/points/overview (PointsController.cs:114-136); rendered by OrganizerPointsOverview (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/OrganizerPointsOverview.razor.cs:34).

ISessionBookmarkUIService

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.UserSessionBookmarks · MMCA.ADC.Engagement.Shared/UserSessionBookmarks/ISessionBookmarkUIService.cs:15 · Level 3 · interface

  • What it is: the narrow UI-side contract that lets the Conference session pages render a bookmark star and toggle it inline, without navigating to an Engagement page and without referencing the Engagement UI assembly.
  • Depends on: UserSessionBookmarkDTO, Result and ErrorType from MMCA.Common.Shared.Abstractions (ISessionBookmarkUIService.cs:1), and the identifier aliases.
  • Concept introduced, the cross-module UI service contract. [Rubric §18, UI Architecture] assesses how one module's page composes another module's capability. The pattern here is optional resolution: Conference's pages hold the dependency as a nullable property and ask the container for it rather than injecting it, BookmarkService = ServiceProvider.GetService<ISessionBookmarkUIService>() (MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionList.razor.cs:89, and the same line at PublicSessionDetail.razor.cs:58), so a host that never called AddEngagementUI() simply renders no stars instead of failing to construct the page. PublicSessionListView then takes it as a nullable [Parameter] (PublicSessionListView.razor.cs:60). [Rubric §7, Microservices Readiness]: this is the presentation-layer twin of what IBookmarkCountService does for the application layer.
  • Concept, Result all the way to the page. Every member reports its outcome as a Result carrying the API's own errors with their ErrorType intact, and the interface doc comment states the rule (ISessionBookmarkUIService.cs:9-13): the calling page branches instead of catching, and only the caller's own OperationCanceledException still propagates. [Rubric §9, API & Contract Design] and [Rubric §24, Forms, Validation & UX Safety]: a "not there" answer and a "the remove failed" answer are different facts, and a boolean cannot tell them apart. That is spelled out for the delete member (:34-38): a missing bookmark answers 404, which arrives as an ErrorType.NotFound failure rather than a bare false.
  • Walkthrough: three members, each shaped by a real UI need.
    • GetBookmarkedSessionIdsAsync(userId, cancellationToken) (ISessionBookmarkUIService.cs:20-21) returns Result<IReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType>>, a map rather than a set. A list page needs both facts at once: which sessions are starred, and the bookmark id it must send to un-star each one, so the dictionary saves a second round trip per row.
    • CreateBookmarkAsync(userId, sessionId, cancellationToken) (:26-28) returns the created DTO inside a Result<T>, which is what lets the caller revert an optimistic star and show the server's own refusal without exception handling.
    • DeleteBookmarkAsync(bookmarkId, sessionId, cancellationToken) (:40-43) takes the session id even though the API key alone identifies the row. The doc comment says why (:31-33): the session-reminder layer (ADR-042 Wave 2) needs to know which session's scheduled local notification to cancel, and the bookmark id cannot answer that after the row is gone.
  • Why it's built this way: the interface lives in .Shared, the assembly Conference already references, while its implementation lives in Engagement's UI project. That split is what makes the star a genuinely optional feature of the Conference page rather than a hard dependency. All three CancellationToken parameters are defaulted here, unlike on IBookmarkCountService, because the callers are Blazor event handlers rather than a generated gRPC client.
  • Where it's used: implemented by SessionBookmarkUIService (MMCA.ADC.Engagement.UI/Services/Bookmarks/SessionBookmarkUIService.cs:26-29), registered scoped in DependencyInjection (MMCA.ADC.Engagement.UI/DependencyInjection.cs:34), consumed by Conference's PublicSessionList, PublicSessionDetail and PublicSessionListView. Its nullable-injection convention is cited as precedent by ISessionLiveUIService (MMCA.ADC.Engagement.Shared/SessionQuestions/ISessionLiveUIService.cs:4-5).

PointsSettings

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSettings.cs:12 · Level 9 · class (sealed)

  • What it is: the configuration object for the points economy: what each earn rule pays and how long the public leaderboard is. Bound from the Points configuration section.
  • Depends on: PointsActivityType. Externals: the options binder (Microsoft.Extensions.Options), through the host's AddOptions<PointsSettings>().Bind(...).
  • Concept introduced, configuration as an operational control, not just a default. [Rubric §29, Resilience, Reliability & Business Continuity] assesses how configuration is bound and consumed, and [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether an operator can degrade a feature without a deploy. A value of 0, or a missing configuration entry (which binds to 0), is a deliberate per-rule kill switch: PointsAwarder reads GetPointsFor(activity) and returns success without writing anything when the value is not positive (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:43-51), so one earn rule can be turned off mid-conference while the rest keep earning, and the ledger is not polluted with zero-value entries (PointsSettings.cs:7-10).
  • Concept, deliberately not ValidateOnStart. The Engagement host binds these options without eager validation, and the inline comment states why (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:123-125): a missing Points section binds every award to the defaults below, and an explicit 0 is the documented kill switch, so there is no value an organizer could set that should stop this host from booting mid-conference. The same comment also explains why the binding lives in the host rather than in AddEngagementModule: the module registration takes no IConfiguration (Program.cs:119-122). Contrast the pattern with CheckInSettings, bound the same way immediately after for the same reason (Program.cs:129-134). [Rubric §13, Observability & Operability]: the failure mode chosen here is "boot and keep serving", not "refuse to start".
  • Walkthrough
    • SectionName = "Points" (PointsSettings.cs:15): the constant the host binds against, so the section name is not duplicated as a literal at the call site (Program.cs:126-127).
    • Six int award properties with defaults: EventCheckIn = 25 (line 18), SessionCheckIn = 10 (line 21), SessionFeedback = 15 (line 24), EventFeedback = 15 (line 27), QuestionAsked = 5 (line 30), SponsorVisit = 20 (line 37). The deployed appsettings.json sets exactly these same values (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/appsettings.json:26-34), so the checked-in defaults are the live economy.
    • SponsorVisit carries the longest doc comment of the six (PointsSettings.cs:32-36) and it is the interesting one: the deep link an attendee scans is shareable, so the once-per-sponsor cap enforced by the subject key plus a bounded value is what makes passing the URL around worthless.
    • LeaderboardSize = 10 (line 40): how many rows the public board shows. GetLeaderboardHandler reads it through Math.Max(settings.Value.LeaderboardSize, 0) before Take(...) (GetLeaderboardHandler.cs:63, :70), so a negative configured value yields an empty board rather than an exception.
    • GetPointsFor(activity) (PointsSettings.cs:48-58): a switch expression over PointsActivityType with a _ => 0 arm (line 57), so an undefined activity is treated exactly like a configured 0 and awards nothing.
  • Why it's built this way: init-only properties keep the bound options immutable after startup, and putting GetPointsFor on the settings object rather than in the awarder means the "what is this worth" lookup has one home; the awarder is left holding only the idempotency and persistence concerns. Keeping the type in *.Shared (not Application) is what lets the host bind it without referencing the Application layer.
  • Where it's used: bound by the Engagement service host (Program.cs:126-127); injected as IOptions<PointsSettings> into PointsAwarder (PointsAwarder.cs:31, :46) and GetLeaderboardHandler (GetLeaderboardHandler.cs:30, :63).
  • Caveats / not-in-source: whether a live production deployment overrides any of these values through environment configuration is not determinable from source; the repository shows only the checked-in appsettings.json values, which match the class defaults.

EngagementRoutePaths

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:8 · Level 0 · class (static)

  • What it is: the single source of truth for every Engagement URL: the four live-layer routes, the two feedback routes, the three badge check-in routes, the two points routes, and the two deep-link-only self-service QR routes.
  • Depends on: nothing first-party. It uses the SessionIdentifierType, EventIdentifierType, SponsorIdentifierType and RoomIdentifierType aliases, plus System.Globalization (EngagementRoutePaths.cs:1).
  • Concept, centralized route paths. [Rubric §25, Navigation & Information Architecture] assesses whether URL strings are declared once or scattered across NavigateTo call sites and @page directives. Every route here is either a const (parameterless) or a static method (id-carrying), so renaming a route touches one file and the compiler finds the call sites. [Rubric §27, Internationalization] shows up in an easy-to-miss detail: each interpolated route is built with string.Create(CultureInfo.InvariantCulture, $"...") (EngagementRoutePaths.cs:12-14, :17-18, :31-32) rather than plain interpolation, so a culture with non-ASCII digits or a different number format can never produce a URL the router will not match.
  • Walkthrough
    • Live layer (EngagementRoutePaths.cs:10-14): const string HappeningNow = "/happening-now", plus SessionDetails, SessionLive and SessionPresent, which build /conference/sessions/{id}, .../live and .../present.
    • Feedback (EngagementRoutePaths.cs:16-18): EventFeedback(eventId) and SessionFeedback(sessionId), whose outputs match the @page templates on the two feedback pages.
    • Badge check-in (EngagementRoutePaths.cs:20-23): MyBadge = "/my-badge", CheckInScan = "/check-in", OrganizerAttendance = "/organizer/attendance", the three routes carried by the pages later in this part.
    • Points (EngagementRoutePaths.cs:25-27): MyPoints = "/points" and OrganizerPointsOverview = "/organizer/points".
    • Self-service QR (EngagementRoutePaths.cs:31-32): SponsorVisit(sponsorId) and RoomCheckIn(roomId) build /engage/sponsors/{id} and /engage/rooms/{id}. The comment above them (EngagementRoutePaths.cs:29-30) records the design decision that matters to a reader: these are deep-link-only landings reached by scanning a printed code, so neither contributes a nav item.
  • Why it's built this way: methods are used wherever an id is embedded so the interpolation (and its invariant culture) lives in one place; plain consts are used for the parameterless routes because they must be usable in attribute and collection-initializer positions, which is exactly how EngagementUIModule consumes them.
  • Where it's used: five of the constants seed the nav items in EngagementUIModule (MMCA.ADC.Engagement.UI/EngagementUIModule.cs:23-28); the feedback and live builders are called from pages that link an attendee onward; the QR builders are what a printed sponsor or room code encodes.

ScanOutcomeKind

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns · MMCA.ADC.Engagement.UI/Pages/CheckIns/CheckInScan.razor.cs:299 · Level 0 · enum (private, nested in CheckInScan)

  • What it is: the four things one check-in attempt can mean to the organizer holding the phone, quoting the type's own summary (CheckInScan.razor.cs:298).
  • Depends on: nothing.
  • Concept, the closed outcome vocabulary behind a UI feed. [Rubric §21, Accessibility] is the reason this is an enum and not a pre-formatted string: the page fans each member out through three separate switch expressions, one for the sentence (OutcomeText, CheckInScan.razor.cs:243-250), one for the alert severity (SeverityFor, :249-256) and one for the icon (IconFor, :258-265), so colour never carries the status alone. The markup pairs the severity with the icon on every row and says so in a comment (CheckInScan.razor:99-102), which is what WCAG 2.1 AA asks for. [Rubric §27, i18n]: because the enum stays a symbol until render time, the wording lives entirely in the page's resource file.
  • Walkthrough: CheckedIn, AlreadyCheckedIn, NotABadge, Failed (CheckInScan.razor.cs:301-304). AlreadyCheckedIn is not an error state: a repeat scan is a normal thing to happen at a door, so it maps to Severity.Warning with a replay icon (:252, :261) rather than to a failure. NotABadge covers a scanner reading a poster or sponsor QR and maps to Severity.Info (:253). Only Failed maps to Severity.Error (:254).
  • Why it's built this way: four named cases keep the render-time mapping exhaustive and reviewable in one screen. All three switch expressions still carry a _ => default arm (:246, :255, :264) so adding a member cannot throw at runtime, only produce the generic failure presentation until the arms are extended.
  • Where it's used: carried by ScanOutcome and consumed only inside CheckInScan; it is private, so it never leaves the page.

SessionAttendanceRow

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns · MMCA.ADC.Engagement.UI/Pages/CheckIns/OrganizerAttendance.razor.cs:128 · Level 0 · record (private sealed, nested in OrganizerAttendance)

  • What it is: one rendered attendance row, a session title paired with its check-in count. It is the view model the organizer attendance table iterates, distinct from the server DTO it is built from.
  • Depends on: nothing first-party (a string and an int).
  • Concept, the page-local view model. [Rubric §18, UI Architecture] assesses whether a page renders raw server contracts or a shape it owns. The stats endpoint returns SessionAttendanceDTO, which carries a SessionId and a Count (MMCA.ADC.Engagement.Shared/CheckIns/Attendance/SessionAttendanceDTO.cs:9-12) and no title, because the Engagement service does not own the session catalog. Rather than resolve a title inside the markup (which would put I/O in the render path), the page projects each DTO into this record once, already labelled and already sorted.
  • Walkthrough: a positional record with Title and Count (OrganizerAttendance.razor.cs:128), constructed inside the projection in BuildSessionRowsAsync (OrganizerAttendance.razor.cs:93-100) and rendered as the table body rows (OrganizerAttendance.razor:58-63), where Title becomes the row header cell and Count is formatted with ToString("N0", CultureInfo.CurrentCulture).
  • Why it's built this way: it is private sealed because nothing outside the page has a use for it, and a record because it is pure data, compared and never mutated after projection.
  • Where it's used: only by OrganizerAttendance, which holds them in the _rows list (OrganizerAttendance.razor.cs:33).

CheckInState

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns.Rooms · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/Rooms/RoomCheckIn.razor.cs:97 · Level 0 · enum (protected, nested)

  • What it is: the five outcomes one scan of a printed room QR can have, as seen by the attendee. It is nested inside RoomCheckIn and is the single field its markup switches on.
  • Depends on: nothing (a bare enum). Its values are produced from CheckInErrorCodes by the page's mapping switch.
  • Concept introduced, the page-owned outcome enum. [Rubric §19, State Management] assesses whether a component's visible states are enumerated or reconstructed ad hoc from a scatter of booleans. This page has exactly one state field, so "loading", "recorded", "nothing scheduled here", "event not published" and "unavailable" are mutually exclusive by construction, and the markup is a flat chain of comparisons (RoomCheckIn.razor:12, :16, :41, :47) with no combination to reason about. [Rubric §9, API & Contract Design] is the other half of the story: two of the refusals arrive as the same HTTP 404, so the state cannot be picked from a status code; the server's stable error code carried back on SelfCheckInOutcome<TResult> is what makes the distinction possible.
  • Walkthrough: five members in the order the attendee meets them. Loading (RoomCheckIn.razor.cs:100) is the initial value of State (RoomCheckIn.razor.cs:34) and covers the in-flight post. Recorded (:103) covers both a fresh check-in and a re-confirmed earlier one; the distinction is RoomCheckInResultDTO.AlreadyCheckedIn, not a separate state. NoCurrentSession (:106) means nothing is scheduled in that room right now, and, per the member comment, an unknown room reads the same way. NotPublished (:109) means the owning event is not published yet. Unavailable (:112) is the catch-all for a disabled feature or an unreachable service.
  • Why it's built this way: collapsing "unknown room" into NoCurrentSession and the feature gate into Unavailable is a deliberate disclosure choice: a printed code that produces a distinguishable answer for a room that does not exist would confirm which room ids do exist. See ADR-072 for the surrounding scan-surface decision.
  • Where it's used: only inside RoomCheckIn (RoomCheckIn.razor.cs:34, :60, :70-75, :83) and its markup. protected rather than private so the bUnit component tests can assert the rendered outcome (see RoomCheckInTests).

VisitState

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns.Sponsors · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/Sponsors/SponsorVisit.razor.cs:97 · Level 0 · enum (protected, nested)

  • What it is: the four outcomes one scan of a printed sponsor-booth QR can have. The sibling of CheckInState, nested inside SponsorVisit, with one fewer member because a sponsor booth has no "what is running here right now" question to answer.
  • Depends on: nothing (a bare enum); its values are chosen from CheckInErrorCodes.
  • Concept: the page-owned outcome enum is taught on CheckInState; the same reasoning applies unchanged here.
  • Walkthrough: Loading (SponsorVisit.razor.cs:100), the initial value of State (:35); Recorded (:103), which again covers both a first visit and a repeat, split in the markup by SponsorVisitResultDTO.AlreadyVisited (SponsorVisit.razor:19); NotPublished (:106); and Unavailable (:109). The mapping is a ternary rather than a switch because only one error code is distinguished (SponsorVisit.razor.cs:73-75): CheckInErrorCodes.EventNotPublished selects NotPublished, and everything else, including a null code, falls to Unavailable.
  • Why it's built this way: the member comment on Unavailable (SponsorVisit.razor.cs:108) folds "feature off", "unknown sponsor" and "service unreachable" into one member, and the markup comment (SponsorVisit.razor:56-57) states the reason: the three are the same sentence to an attendee holding a phone, and naming them apart would only confirm which sponsor codes exist. That makes the collapse a [Rubric §26, Front-End Security] decision (no existence disclosure through error differentiation) as much as a copy decision.
  • Where it's used: only inside SponsorVisit (SponsorVisit.razor.cs:35, :63, :73-75, :83) and its markup; protected so SponsorVisitTests can drive it.

ScanOutcome

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns · MMCA.ADC.Engagement.UI/Pages/CheckIns/CheckInScan.razor.cs:296 · Level 1 · record (private sealed, nested in CheckInScan)

  • What it is: one line in the organizer's on-screen scan feed: what happened to a badge, and to whom, in the order the organizer saw it (CheckInScan.razor.cs:295).
  • Depends on: ScanOutcomeKind.
  • Concept, the immutable UI event record. [Rubric §19, State Management] assesses where transient UI state lives and how it changes. The feed is a plain List<ScanOutcome> field on the page (CheckInScan.razor.cs:58) whose entries are never edited after creation: a new scan is inserted at index 0 and the list is trimmed from the tail (AddOutcome, CheckInScan.razor.cs:234-241, bounded by the const int MaxOutcomes = 10 at :21). Immutable entries plus a bounded list mean the feed cannot grow without limit during a long scanning session and cannot be retroactively rewritten by a later scan.
  • Walkthrough: a positional record with AttendeeName and Kind (CheckInScan.razor.cs:296). It is constructed in four places, and the differences are the interesting part: new ScanOutcome(string.Empty, ScanOutcomeKind.NotABadge) when the scanned text is not a badge payload (:131), a named Failed when a manual write comes back a failure (:189), a Failed carrying whatever name the caller had when the response itself is a failure (:216), and the success path in RecordResultAsync, which resolves a name and picks AlreadyCheckedIn or CheckedIn from the response (:226-228). Where no name is available the page substitutes the localized Outcome.UnknownAttendee string (:227) rather than rendering an empty row.
  • Why it's built this way: keeping the name on the record (instead of re-resolving it at render time) means the feed is pure data by the time the markup runs, so re-rendering never triggers another lookup.
  • Where it's used: only inside CheckInScan; rendered as one MudAlert per entry (CheckInScan.razor:97-105).

MyBadge

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns · MMCA.ADC.Engagement.UI/Pages/CheckIns/MyBadge.razor.cs:15 · Level 5 · class (partial)

  • What it is: the code-behind for the attendee's own badge page (@page "/my-badge", MyBadge.razor:1). It fetches the caller's opaque badge credential, encodes it as a QR code, and renders the attendee's name beside it as plain text.
  • Depends on: ICheckInUIService (MyBadge.razor.cs:17), BadgePayload (:52), and MyBadgeDTO as the value inside the fetch result. Externals: Blazor's [CascadingParameter] Task<AuthenticationState> (:19-20), MudBlazor's BreadcrumbItem/Icons, the shared QrCodeImage, PageLoadingState and PageErrorState components from MMCA.Common.UI.Components, and an injected IStringLocalizer<MyBadge> named L declared in the markup (MyBadge.razor:5).
  • Concept introduced, the opaque-credential badge. [Rubric §11, Security] and [Rubric §30, Compliance, Privacy & Data Governance] both apply, and the class summary states the rule (MyBadge.razor.cs:10-14): the QR carries only the credential, nothing about the person. Anyone photographing a badge across the room learns a GUID, not a name or an email, and the server remains the sole authority on which attendee that credential belongs to (MMCA.ADC.Engagement.Shared/CheckIns/Badges/BadgePayload.cs:6-11). The human-readable name is rendered beside the code as ordinary text (MyBadge.razor:39) so an organizer can confirm the badge belongs to the person holding it; it is never inside the code. [Rubric §21, Accessibility]: the QR image carries an AltText from the resource file, and its PixelsPerModule="14" plus Medium error correction are documented in the markup comment (MyBadge.razor:31-32) as making the code readable at arm's length and tolerant of glare or a fingerprint.
  • Concept, distinguishing "no badge" from "the fetch failed". [Rubric §24, Forms, Validation & UX Safety]: the page reads the result twice, once for the happy path and once for the shape of the failure. badgeResult.TryGetValue(out var badge) unwraps a success (:50); else if (!badgeResult.IsNotFound()) sets the error message only when the failure was something other than a 404 (:54-59), and the inline comment records that a not-found is simply an attendee who has no badge yet. The markup then has three distinct terminal states, not two: the error panel (MyBadge.razor:16-19), the empty-state alert when _payload is still null (:20-25), and the badge card (:26-45).
  • Walkthrough (teaching order)
    • State: a CancellationTokenSource field (MyBadge.razor.cs:22), the breadcrumb list (:24), a protected bool IsLoading that starts true (:27), and three privates for the error message, the encoded payload and the display name (:29-31).
    • OnInitializedAsync (:33) builds the two-item breadcrumb trail (:35-39), then, inside a try, awaits the cascaded AuthenticationState for state.User.Identity?.Name (:43-47) and calls CheckInService.GetMyBadgeAsync(_cts.Token) (:49). Only on a successful unwrap does it compute _payload = BadgePayload.Format(badge.Credential) (:52).
    • Failure handling is the house pattern: OperationCanceledException is swallowed as expected during disposal (:61-64), and IsLoading is cleared in finally (:65-68) so the page can never be stuck on the spinner. Because the service returns a Result, an ordinary server refusal never reaches a catch at all.
    • Dispose(bool) / Dispose() (:71-93) are the standard disposable pattern with a _disposed guard, cancelling and disposing the token source so an in-flight fetch stops when the attendee navigates away.
  • Why it's built this way: encoding the payload on the client from a credential the server issued keeps the QR format in exactly one place, BadgePayload.Format (BadgePayload, MMCA.ADC.Engagement.Shared/CheckIns/Badges/BadgePayload.cs:20), which is the same class the scanner side parses with (:30). Display and check-in cannot drift apart because they share the type and its mmca-adc:badge: prefix (:15).
  • Where it's used: routed at /my-badge behind a plain [Authorize] attribute (MyBadge.razor:2), any signed-in attendee, and surfaced in the nav by EngagementUIModule (EngagementUIModule.cs:24). Its QR is what CheckInScan reads at a session door.
  • Caveats / not-in-source: the resource strings and the QrCodeImage rendering behaviour live outside this file; this section covers the code-behind and the small amount of markup cited.

RoomCheckIn

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns.Rooms · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/Rooms/RoomCheckIn.razor.cs:20 · Level 5 · class (partial)

  • What it is: the code-behind for the room QR landing page (@page "/engage/rooms/{RoomId:int}", RoomCheckIn.razor:1). An attendee scans the code printed beside a room door, signs in if needed, lands here, and the page records the check-in itself. The scan never names a session: the server decides which session that room is hosting at this instant.
  • Depends on: ICheckInUIService (the post), SelfCheckInOutcome<TResult> (the return shape), RoomCheckInResultDTO (the payload), CheckInErrorCodes (the refusal vocabulary), IToastService (the snackbar), and its own nested CheckInState. Externals: Blazor ComponentBase (RendererInfo, [Parameter]), MudBlazor BreadcrumbItem / MudAlert / Icons, System.Globalization, and an injected IStringLocalizer<RoomCheckIn> named L supplied by the markup (RoomCheckIn.razor:5).
  • Concept introduced, the write-on-arrival landing page. [Rubric §18, UI Architecture] assesses component/behavior separation and render-mode discipline. This page inverts the usual "render, then let the user press something" shape: the arrival is the interaction, so the round trip runs from OnInitializedAsync. That makes the Blazor render-mode question load-bearing, because a prerendered pass and the interactive pass both run OnInitializedAsync, and the whole point of the page is a write. The two guards at RoomCheckIn.razor.cs:47-50 (!RendererInfo.IsInteractive and a _posted latch) are what keep one arrival to one post. [Rubric §19, State Management] covers the single-field state model taught on CheckInState. [Rubric §11, Security] applies through @attribute [Authorize] (RoomCheckIn.razor:2): the page identifies the attendee from the authenticated session, so a scanned code carries no identity of its own.
  • Walkthrough (teaching order)
    • [Parameter] RoomIdentifierType RoomId (RoomCheckIn.razor.cs:23) is bound from the {RoomId:int} route constraint, so an unparseable code never reaches the component. The injected ICheckInUIService and IToastService follow (:25-26), then a CancellationTokenSource (:28), the breadcrumb list (:30) and the _posted latch (:31).
    • State (RoomCheckIn.razor.cs:34) starts at CheckInState.Loading and is protected with a private setter; Result (:37) holds the RoomCheckInResultDTO and is documented as present only in the Recorded state.
    • OnInitializedAsync (:39) builds the two-item breadcrumb trail first (:41-45) so even the non-interactive prerender has a complete chrome, then returns early if the render is not interactive or the post already ran (:47-50). Only after that does it set _posted = true (:52) and await CheckInService.RecordRoomCheckInAsync(RoomId, _cts.Token) (:56).
    • The success branch (:57-68) assigns Result, moves to CheckInState.Recorded, and raises a success toast only when Result?.AlreadyCheckedIn == false (:62-65): a rescan is a normal thing to do, so it renders as information (RoomCheckIn.razor:22-24) rather than as a second celebration.
    • The refusal branch (:70-75) is a switch expression over outcome.ErrorCode: CheckInErrorCodes.NoCurrentSession becomes NoCurrentSession, CheckInErrorCodes.EventNotPublished becomes NotPublished, and every other code (including null) falls through the discard arm to Unavailable. This is the client-side consumer of the error-code contract that SelfCheckInOutcome<TResult> exists to carry.
    • catch (OperationCanceledException) (:77-80) is silent (expected on disposal); any other exception lands on Unavailable (:81-84), so a transport fault and a disabled feature look identical to the attendee.
    • FormatCheckedInOn (:93-94) renders the recorded instant with ToLocalTime() and the "g" format under CultureInfo.CurrentCulture. The XML comment (:87-92) gives the reason: the attendee is comparing the timestamp against the clock on the wall beside them. [Rubric §27, Internationalization] covers both halves of that, the culture-aware format and the fact that every visible string is an L["..."] resource lookup (ADR-027).
    • Dispose(bool) / Dispose() (:117-137) implement the standard disposable pattern with a _disposed latch, cancelling and disposing _cts so an in-flight post stops when the attendee navigates away.
  • Why it's built this way: the class comment (RoomCheckIn.razor.cs:10-19) states the two constraints. The write runs on the interactive render only, and only once per page instance: a prerender must not spend a round trip, and a browser back button must not read as a second arrival. The server is idempotent per session regardless (a repeat returns AlreadyCheckedIn), so the latch is about not making a pointless call rather than about correctness. Resolving the session server-side from the room, instead of encoding it in the QR, means the printed code never expires when the schedule moves (ADR-072). The page complements organizer badge scanning at the door rather than replacing it: one session yields one check-in by either path, which is exactly what the "already checked in" branch says (RoomCheckIn.razor:20-21).
  • Where it's used: the target of the room QR rendered on the Conference room detail page (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:58, a QrCodeButton over ConferenceRoutePaths.RoomCheckInLink, ConferenceRoutePaths.cs:61) per ADR-071. The post lands on CheckInsController's room-visits endpoint (CheckInsController.cs:163, feature-gated on EngagementFeatures.RoomCheckIn at CheckInsController.cs:165) and is handled by RecordRoomCheckInHandler. [Rubric §28, Front-End Testing]: covered by RoomCheckInTests (bUnit).
  • Caveats / not-in-source: the outcome copy, the success card and the points hint live in RoomCheckIn.razor (:30-35) and the resource files, not in this code-behind. Whether a given deployment has EngagementFeatures.RoomCheckIn on is configuration, not source.

SponsorVisit

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns.Sponsors · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/CheckIns/Sponsors/SponsorVisit.razor.cs:21 · Level 5 · class (partial)

  • What it is: the code-behind for the sponsor-booth QR landing page (@page "/engage/sponsors/{SponsorId:int}", SponsorVisit.razor:1). The attendee scans the booth's printed code with the phone camera, signs in if needed, and lands here; the page records the visit itself, so the whole interaction is one scan with nothing to press.
  • Depends on: ICheckInUIService, SelfCheckInOutcome<TResult>, SponsorVisitResultDTO, CheckInErrorCodes, IToastService, its nested VisitState, and (from the markup) ConferenceRoutePaths for the follow-on "all sponsors" link. Externals as on RoomCheckIn: Blazor RendererInfo / [Parameter], MudBlazor components, System.Globalization, and an injected IStringLocalizer<SponsorVisit> (SponsorVisit.razor:6).
  • Concept: the write-on-arrival landing page is taught on RoomCheckIn; this page is the same shape with a simpler refusal map. The one difference worth naming is what happens after success: [Rubric §25, Navigation & Information Architecture] assesses whether a dead-end page gives the reader somewhere to go, and this one ends with a filled "all sponsors" button back into the public sponsor list (SponsorVisit.razor:42-48), which the room page has no equivalent of because a room scan is followed by sitting down.
  • Walkthrough of what differs from RoomCheckIn
    • [Parameter] SponsorIdentifierType SponsorId (SponsorVisit.razor.cs:24) instead of a room id; State defaults to VisitState.Loading (:35) and Visit holds a SponsorVisitResultDTO (:38).
    • OnInitializedAsync (:40) carries an inline comment naming the pattern it follows (:48-49): the visit is a write, so it stays off the prerender pass, and the interactive instance is the one that owns the round trip (the ADCHome pattern). The same !RendererInfo.IsInteractive || _posted guard follows (:50-53), then CheckInService.RecordSponsorVisitAsync(SponsorId, _cts.Token) (:59).
    • Success (:60-71) sets Visit, moves to VisitState.Recorded, and toasts only on a first visit (Visit?.AlreadyVisited == false, :65-68).
    • The refusal map (:73-75) is a ternary, not a switch: only CheckInErrorCodes.EventNotPublished is distinguished; everything else is Unavailable. There is no room-style "nothing scheduled here" case, because a booth is not on a schedule.
    • FormatVisitedOn (:93-94) is the local-time formatter, identical in mechanism to the room page's, with the same reason recorded in its XML comment (:87-92).
    • Dispose(bool) / Dispose() (:114-134) are the same disposable pattern over _cts.
  • Why it's built this way: the class comment (SponsorVisit.razor.cs:10-20) is explicit that the server is idempotent per sponsor regardless of the guard, so the interactive-only latch is about not making a pointless round trip rather than about correctness. Recording the visit without a button press is the point of the surface: a sponsor booth interaction that costs an attendee two taps is an interaction most attendees will not complete, which is the engagement argument ADR-072 records for attendee-self-recorded scan surfaces.
  • Where it's used: the target of the sponsor QR rendered on the Conference sponsor detail page (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:70, a QrCodeButton over ConferenceRoutePaths.SponsorVisitLink, ConferenceRoutePaths.cs:60). The post lands on CheckInsController's sponsor-visits endpoint (CheckInsController.cs:132, feature-gated on EngagementFeatures.SponsorVisits at CheckInsController.cs:134, see EngagementFeatures and ADR-031) and is handled by RecordSponsorVisitHandler. Component-tested by SponsorVisitTests.
  • Caveats / not-in-source: the confirmation card, the points hint and the "already visited" copy live in SponsorVisit.razor (:29-40) and its resources. How many points a visit earns is decided server-side by the points earn rules, not here.

AttendeeSearchPanel

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns · MMCA.ADC.Engagement.UI/Pages/CheckIns/AttendeeSearchPanel.razor.cs:16 · Level 7 · class (partial, DataGridListPageBase<AttendeeSummary>)

  • What it is: the manual check-in fallback, a paged, sortable, mobile-aware search over the Identity users endpoint with a per-row check-in action. It is a component, not a page: it has no @page directive and is embedded by CheckInScan.
  • Depends on: DataGridListPageBase<TDto> as its base (AttendeeSearchPanel.razor.cs:16), IAttendeeLookupService plus AttendeeSummary and AttendeeSearchField (MMCA.ADC.Engagement.UI/Services/Lookups/IAttendeeLookupService.cs:15, :39), MobileInfiniteScrollList<TItem> and ListPageActions, and Result for the mobile fetch signature (:3, :86). Externals: MudBlazor MudDataGrid/GridState/GridData, and an injected IStringLocalizer<AttendeeSearchPanel> (AttendeeSearchPanel.razor:4).
  • Concept introduced, extracting a list surface out of a page. [Rubric §15, Best Practices & Code Quality] and [Rubric §18, UI Architecture]: the class summary records the reason for the split (AttendeeSearchPanel.razor.cs:10-15), namely that the page owns the scan loop and its own cancellation while the grid keeps the shared base's behaviour (server paging, mobile card layout, state restore) unchanged. Inheriting the framework base rather than hand-rolling a grid is what buys the mobile path: [Rubric §22, Responsive & Cross-Browser] shows in ReloadActiveLayoutAsync (:59-60), which delegates to ListPageActions.ReloadActiveLayoutAsync(IsMobile, _infiniteList, _dataGrid) so the same filter change reloads either the desktop data grid or the mobile infinite-scroll list, whichever one is live.
  • Walkthrough
    • Contract with the base: Title from the localizer (:19) and GridRef => _dataGrid (:32), which is how the base drives ReloadServerData(); OnMobileDataRequestedAsync (:55) routes the base's mobile hook back through the same reload, and RetryLoadAsync (:57) is what the empty-state retry button calls.
    • Parameters from the host page: CanCheckIn (:24) gates the per-row action, and OnCheckIn (:27) is the EventCallback<AttendeeSummary> raised by CheckInAsync (:130). The component performs no write of its own; it reports which row was actioned and lets the page decide.
    • Filter persistence: SaveFilters / RestoreFilters (:39-52) round-trip the search term and the chosen field through the base's state store, with Enum.TryParse falling back to LastName if the stored value is unreadable (:49-51).
    • Search handling: OnSearchChangedAsync and OnSearchFieldChangedAsync (:62-72) each set a field and reload the active layout. The markup wires the text field with Immediate="true" DebounceInterval="300" (AttendeeSearchPanel.razor:11), so typing does not fire a request per keystroke, a [Rubric §23, Front-End Performance] detail.
    • Data loading: LoadServerData (:74-84) hands the base a lambda that unpacks the grid's filter dictionary into the lookup service's three optional arguments plus paging and sorting; FetchMobilePageAsync (:86-99) does the same for the mobile list and returns Task<Result<(IReadOnlyList<AttendeeSummary> Items, int TotalItems)>>, pinning the sort to "LastName" / "asc" since the card layout has no sort UI. The markup binds it through FetchPageResult (AttendeeSearchPanel.razor:29), the Result-aware overload, which is what lets a failed page render ListNoRecordsContent with LoadFailed and a retry rather than an empty list (:85).
    • ApplySearchTerm (:101-121) is the load-bearing piece and its doc comment explains it (:101-104, echoed in the markup at :14): the Identity users endpoint ANDs its filters, so one search box broadcast across email, first name and last name would match nobody. The single term is therefore applied to exactly the field the organizer picked, as a "contains" filter (:112-120). FilterValue (:123-128) then reads a filter back out, treating whitespace as absent.
  • Why it's built this way: an organizer at a door needs the fallback to be fast and forgiving, and the shared base already solved paging, mobile layout and state restore for every other list in the app. Raising an EventCallback instead of calling the check-in service directly keeps the write path (and its scope, target and error reporting) in one place, the page. [Rubric §21, Accessibility]: both action buttons carry a per-row aria-label built from the attendee's display name (AttendeeSearchPanel.razor:42, :78), so a screen-reader user hears which person a "Check in" button belongs to.
  • Where it's used: rendered unconditionally near the bottom of the scan page, <AttendeeSearchPanel CanCheckIn="CanCheckIn" OnCheckIn="ManualCheckInAsync" /> (CheckInScan.razor:113). The comment above it (CheckInScan.razor:108-109) states the intent: on web and Windows heads there is no camera, so this search is the check-in surface.

CheckInScan

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns · MMCA.ADC.Engagement.UI/Pages/CheckIns/CheckInScan.razor.cs:21 · Level 10 · class (partial)

  • What it is: the organizer check-in surface (@page "/check-in", [Authorize(Roles = "Organizer")], CheckInScan.razor:1-2). On a head with a camera it loops the shared barcode scanner and posts each badge payload; everywhere else the manual search below the scan area is the whole page. Both paths write through the same endpoints and report the attendee by name.
  • Depends on: ICheckInUIService, IAttendeeLookupService, ISessionLookupService, ILiveEventUIService, IBarcodeScannerService and IToastService (CheckInScan.razor.cs:26-31); the shared contracts BadgePayload, CheckInScope, CheckInAttendeeRequest, ManualCheckInRequest, CheckInResultDTO; LiveEventContext and SessionInfo; Result and ErrorType (:4); and its own nested ScanOutcome / ScanOutcomeKind plus the AttendeeSearchPanel component.
  • Concept introduced, the capability-gated scan loop. [Rubric §18, UI Architecture] and the device-capability abstraction of ADR-042 meet here. ScannerAvailable => Scanner.IsSupported (:38, with the ADR named in the comment at :37) is the whole capability switch: the markup renders the scan card only when it is true (CheckInScan.razor:68), and every head that cannot open a camera falls through to the manual panel with no conditional compilation and no per-platform page. [Rubric §11, Security]: scanned text is untrusted input, so BadgePayload.TryExtractCredential screens it before any request goes out (:129), and a poster or sponsor QR is reported as a skipped scan rather than an error (:127-128). [Rubric §24, Forms, Validation & UX Safety]: CanCheckIn (:41-42) refuses to start a scan or a manual write until a target exists, and the markup renders an inline "pick a target" alert when it is false (CheckInScan.razor:59-64).
  • Walkthrough (teaching order)
    • Constants and state: MaxOutcomes = 10 bounds the on-screen feed (:21); the CancellationTokenSource (:30), IsLoading (:35), _loadError, _liveEvent, _sessions and _selectedSessionId (:44-47), the scope field, the _isScanning flag and the _outcomes list (:52-55).
    • Default scope: _scope = CheckInScope.Session with a comment that is real operational knowledge (:49-51, repeated for the reader in the markup at CheckInScan.razor:33-35), namely that the conference runs door and arrival check-in through TicketLeap, so session check-in is the working path and Event scope is kept for future info-desk use.
    • OnInitializedAsync (:57) resolves the current live event (:67) and returns early when there is none (:68-71), then loads the session catalog once through a Result-returning lookup (:73-78) and filters it to that event, ordering by start time with ?? DateTime.MaxValue so unscheduled sessions sort last, then by title under StringComparer.CurrentCulture (:80-83). The same swallow-cancel, always-clear-IsLoading shape as the other pages (:85-92).
    • Target selection: OnScopeChangedAsync stops any running scan when the scope changes (:95-100), which prevents a scan started against one target from continuing under another; OnSessionChanged just records the selection (:102).
    • StartScanningAsync (:109-147) is the loop. It refuses to start twice or without a target (:111-114), then repeatedly awaits Scanner.ScanAsync(_cts.Token) while _isScanning holds and cancellation has not been requested (:119-121). A null payload means cancelled or permission denied, and the documented behaviour (:104-108) is that the loop ends quietly, never with an error dialog (:122-125). A non-badge payload adds a NotABadge outcome and continues (:129-133). Otherwise it submits and calls StateHasChanged() so the feed paints between scans (:135-136).
    • SubmitScanAsync (:151-165) builds a CheckInAttendeeRequest carrying the credential, the scope, the event id and, only in session scope, the selected session (:153-159). It does not catch anything: the write returns a Result that RecordResultAsync turns into a Failed row while the loop keeps scanning, and a genuine cancellation propagates to the loop's own handler (:161-164).
    • ManualCheckInAsync (:168-206) is the fallback the panel raises. It toasts a warning when no target is selected (:170-174), builds a ManualCheckInRequest by user id (:178-184), and on a failed result adds a named Failed outcome (:189) plus a toast whose wording depends on why it failed: a refusal the API stated is shown verbatim through result.LocalizedErrorMessage(L), while an ErrorType.Unexpected fault keeps the page's own wording instead of raw diagnostic text (:191-196). [Rubric §11, Security] and [Rubric §13, Observability & Operability]: that branch is the boundary between what an organizer may read and what belongs in the logs.
    • RecordResultAsync (:212-229) turns one response into an outcome. A failed Result becomes a Failed row (:214-218). The scan path has no name to start from because a badge carries only a credential, so it resolves one via AttendeeLookup.GetDisplayNameAsync(checkIn.UserId, ...) (:220-224); the manual path already has one and skips the lookup. Finally the response's AlreadyCheckedIn flag selects the outcome kind (:228), which is how a repeat scan reads as a recognisable warning instead of an error.
    • Presentation helpers OutcomeText / SeverityFor / IconFor (:240-265) and the bounded AddOutcome (:231-238), covered under ScanOutcomeKind.
    • Dispose(bool) (:269-284) clears _isScanning before cancelling and disposing the token source (:278-280), so the loop's condition fails on the next iteration as well as its await being cancelled.
  • Why it's built this way: one page serving both a camera head and a keyboard head is a deliberate choice, and it is only affordable because the scan capability is behind an injected interface. Treating a duplicate as a first-class outcome rather than an error is the other design decision that matters at a real door: the organizer needs to see who the badge belongs to either way, which is why CheckInResultDTO carries AlreadyCheckedIn instead of returning a conflict (MMCA.ADC.Engagement.Shared/CheckIns/CheckInResultDTO.cs:3-7, :14).
  • Where it's used: routed at /check-in, Organizer-only, and surfaced in the nav by EngagementUIModule with the same role requirement (EngagementUIModule.cs:25). It writes through CheckInService to the CheckInsController endpoints.
  • Caveats / not-in-source: what the camera actually does on each platform is behind IBarcodeScannerService and is covered in the device-capability chapter, not here.

OrganizerAttendance

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.CheckIns · MMCA.ADC.Engagement.UI/Pages/CheckIns/OrganizerAttendance.razor.cs:17 · Level 10 · class (partial)

  • What it is: the organizer attendance rollup for the current event (@page "/organizer/attendance", [Authorize(Roles = "Organizer")], OrganizerAttendance.razor:1-2): one headline arrival figure plus one row per session that has at least one check-in.
  • Depends on: ICheckInUIService, ILiveEventUIService, ISessionLookupService (OrganizerAttendance.razor.cs:19-21), AttendanceStatsDTO and SessionAttendanceDTO, and its own nested SessionAttendanceRow. Externals: MudBlazor MudSimpleTable/BreadcrumbItem, System.Globalization in the markup (OrganizerAttendance.razor:3), and an injected IStringLocalizer<OrganizerAttendance> (:6).
  • Concept, client-side enrichment across a service boundary. [Rubric §7, Microservices Readiness] and [Rubric §12, Performance & Scalability] both apply. The class summary states the situation (OrganizerAttendance.razor.cs:11-16): the stats endpoint reports session identifiers only, because Engagement owns check-ins and Conference owns the session catalog, so titles have to be joined somewhere. This page does it in the client, and BuildSessionRowsAsync documents the choice (:71-74): the whole catalog is fetched once rather than one request per row, because a busy conference day reports dozens of sessions at a time. That is the N+1 avoidance rule applied to a UI join.
  • Walkthrough
    • State: the token source (:21), breadcrumbs (:23), IsLoading (:26), the error string, event name, the raw _stats and the projected _rows (:28-31).
    • OnInitializedAsync (:33) resolves the live event and returns early if there is none (:43-47), stores its name (:49), fetches the rollup with GetAttendanceStatsAsync(liveEvent.EventId, _cts.Token) (:51), sets the localized load error and returns when the Result is a failure (:52-56), then builds the rows (:58-59). Same swallow-cancel and clear-IsLoading discipline as its siblings (:61-68). Note the three distinct render states the markup then keys off: error (OrganizerAttendance.razor:17-20), no live event with _stats still null (:21-26), and the rollup itself (:27-67).
    • BuildSessionRowsAsync (:75) short-circuits to an empty list when the rollup has no session rows, avoiding a pointless catalog fetch (:77-81). A failed catalog fetch clears the rows and raises the same load error rather than silently rendering untitled rows (:83-89). Otherwise it projects each DTO into a SessionAttendanceRow, substituting a localized Sessions.UnknownTitle when the catalog does not know the session (:93-95), which keeps a deleted or cross-event session from rendering a blank row. Sorting is OrderByDescending(Count) then ThenBy(Title, StringComparer.CurrentCulture) (:97-98), a culture-aware tie-break, so the busiest sessions lead.
    • Dispose (:103-123) is the same disposable pattern as the other check-in pages.
  • Why it's built this way: sorting and labelling once, on load, keeps the markup a pure loop over _rows (OrganizerAttendance.razor:58-63) with no I/O in the render path. [Rubric §21, Accessibility]: that markup is a real MudSimpleTable with a <caption>, <th scope="col"> headers and a <th scope="row"> per session (OrganizerAttendance.razor:49-63), not a styled div grid, so a screen reader announces the counts with their session names. [Rubric §27, i18n]: both the headline figure and each row count are formatted ToString("N0", CultureInfo.CurrentCulture) (:34, :62).
  • Where it's used: routed at /organizer/attendance and surfaced as an Organizer-only nav item by EngagementUIModule (EngagementUIModule.cs:26). Its numbers come from the same check-in rows that CheckInScan writes.

EngagementUIModule

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI/EngagementUIModule.cs:17 · Level 11 · class (sealed)

  • What it is: the Engagement module's UI descriptor. It implements IUIModule to contribute six navigation items, one invisible layout component, and its own assembly for Blazor component discovery.
  • Depends on: IUIModule, NavItem and NavSection from MMCA.Common.UI.Common (EngagementUIModule.cs:4-5), RoleNames from MMCA.Common.Shared.Auth (:3), EngagementRoutePaths, LiveEventListener (:2), MudBlazor Icons, and System.Reflection.
  • Concept, the pluggable UI module descriptor. [Rubric §18, UI Architecture] assesses how a modular monolith composes its front end without the host hard-coding each module's menu: every module ships an IUIModule, and the shared shell merges their nav items, layout components and assemblies. [Rubric §25, Navigation & IA]: the menu is declarative data here, not imperative wiring. [Rubric §11, Security]: three of the six items carry RequiredRole: RoleNames.Organizer (:25, :26, :28) so the shared NavMenu hides them from attendees; this is menu trimming, and the real enforcement remains the [Authorize(Roles = "Organizer")] attribute on each page. [Rubric §27, i18n]: per the ADR-027 comment (:19-20), each Title is a resource key, resolved by NavMenu against the co-located EngagementUIModule.resx pair at render time through the typeof(EngagementUIModule) title-resource pointer passed to every item, so the labels localize instead of being hard-coded English.
  • Walkthrough
    • NavItems (:21-29) is a collection expression of six entries, each pairing a resource key, a centralized route and an icon: Nav.HappeningNow to HappeningNow (:23), Nav.MyBadge to MyBadge (:24), Nav.CheckIn to CheckInScan (:25, Organizer), Nav.Attendance to OrganizerAttendance (:26, Organizer), Nav.MyPoints to MyPoints (:27), and Nav.PointsOverview to OrganizerPointsOverview (:28, Organizer). Nav.MyPoints is the only one that sets Section: NavSection.User, which places it in the user section beside the profile entry rather than in the main menu.
    • LayoutComponentTypes (:31): [typeof(LiveEventListener)], an invisible component the shell renders on every page; the class summary says it joins the event live channel while the event is live (:14-15).
    • Assembly (:33): typeof(EngagementUIModule).Assembly, so Blazor's router and component discovery can find this module's routable pages.
  • Why it's built this way: exposing nav, layout components and the assembly as read-only properties lets the host treat every module identically, and keeping the descriptor beside the routes it points at means adding a page is a two-line change in one project. Note what is deliberately absent: the two self-service QR routes (EngagementRoutePaths.cs:29-32) contribute no nav item because they are reachable only by scanning a printed code.
  • Where it's used: registered as a singleton IUIModule by DependencyInjection's AddEngagementUI (MMCA.ADC.Engagement.UI/DependencyInjection.cs:76); consumed by the shared shell's NavMenu and router.

DependencyInjection

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI/DependencyInjection.cs:22 · Level 12 · class (static)

  • What it is: the Engagement UI composition root. One static class exposing an AddEngagementUI() extension on IServiceCollection that registers every Engagement UI service (bookmarks, reminders, feedback, check-in, points, lookups, the live layer, notification scoping) plus the EngagementUIModule descriptor.
  • Depends on: IServiceCollection and Microsoft.Extensions.DependencyInjection.Extensions (for TryAddSingleton, :2), the Engagement UI service interfaces and implementations, ISessionBookmarkUIService and ISessionLiveUIService from .Shared (:3-4), and IUIModule plus INotificationScopeProvider from the Common UI framework (:6-7).
  • Concept, extension(IServiceCollection) registration. [Rubric §5, Vertical Slice] and [Rubric §1, SOLID] assess whether a slice owns its own wiring: this is the single place the Engagement UI declares its services, so a host calls AddEngagementUI() and stays ignorant of the internals. The C# preview extension(IServiceCollection services) block (DependencyInjection.cs:24) is the DI-registration idiom used across the codebase (see the primer on extension(T) members), which is what lets AddEngagementUI read as an instance method on the collection.
  • Walkthrough: AddEngagementUI() (:23) registers in labelled groups, all scoped (per circuit) unless noted.
  • Why it's built this way: one extension method per module UI keeps registration cohesive and lets a host compose modules by convention. The TryAdd versus Add choices are the part worth reading closely: TryAdd where this module is a fallback provider (TimeProvider), plain Add where it must win over a framework default (the notification scope provider). [Rubric §29, Resilience, Reliability & Business Continuity]: getting that pair wrong is exactly how the notification bell would silently stop scoping to the current event.
  • Where it's used: called by the Blazor UI host's startup when the Engagement module is enabled, alongside the other modules' AddXUI() calls.

FeedbackAnswerModel

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.Feedback · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/FeedbackAnswerModel.cs:23 · Level 0 · class (public sealed)

  • What it is: the form model for one answer on the two attendee feedback pages. It is the two-way binding target behind every question field and, more importantly, the single declaration site for the free-text length rule that both pages enforce.
  • Depends on: nothing first-party. Externals: System.ComponentModel.DataAnnotations ([MaxLength]). It is consumed through ModelValidation.ForProperty and DataAnnotationsModelValidator, both from MMCA.Common.UI.
  • Concept introduced, the annotated form model as the one rule declaration. [Rubric §24, Forms, Validation & UX Safety] assesses whether input rules are declared once or restated per field. The naive Blazor shape writes a MaxLength on the markup, a Counter beside it, and a hand-rolled length check in the submit path: three copies of the same number that drift independently. Here the [MaxLength] attribute at FeedbackAnswerModel.cs:33 is the rule, AnswerValueMaxLength (:30) is the number, and the markup reads the constant for both the input cap and the character counter (EventFeedback.razor:68-69, SessionFeedback.razor:79-80). [Rubric §19, State Management] covers the other half of its job: each page holds a Dictionary<QuestionIdentifierType, FeedbackAnswerModel> of in-progress answers (EventFeedback.razor.cs:54, SessionFeedback.razor.cs:54) rather than editing the DTO it will eventually post, so an unsaved edit never round-trips through a server contract. [Rubric §27, Internationalization]: the ErrorMessage is not a sentence but a resource key, "Field.MaxLengthError", resolved by each page's own localizing validator (ADR-027), so the shared model produces a per-page translation without the model knowing anything about locales.
  • Walkthrough
    • public const int AnswerValueMaxLength = 4000 (FeedbackAnswerModel.cs:30) is the client-side mirror of the server invariant. The XML comment names its source, and it checks out: EventInvariants.AnswerValueMaxLength is 4000 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:59) and SessionInvariants.AnswerValueMaxLength is the same (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:37). The same number also sizes the column (EventQuestionAnswerConfiguration.cs:26, SessionQuestionAnswerConfiguration.cs:26), so a value the form accepts is a value the table can hold.
    • TextValue (:34) is the string? bound by the text and email question types, and carries the [MaxLength(AnswerValueMaxLength, ErrorMessage = "Field.MaxLengthError")] annotation (:33).
    • RatingValue (:37) is a plain int with no annotation, because a rating is picked from a five-star control rather than typed. Zero is the unanswered sentinel: GetAnswerValue treats RatingValue > 0 as the only answered state (EventFeedback.razor.cs:290-292, SessionFeedback.razor.cs:321-323), so an untouched rating serializes to null and is skipped on submit.
    • Both members are settable auto-properties, deliberately. MudBlazor binds with @bind-SelectedValue and @bind-Value (EventFeedback.razor:50, :55, :65), which needs a writable property, so a record with init-only members could not serve as the target. The mutability is a consequence of the binding contract, not a lapse from the workspace's required/init default.
  • Why it's built this way: the type doc (FeedbackAnswerModel.cs:5-22) records the one design tension worth understanding. Both pages render a dynamic question list, so whether a given answer is required is per-question data (QuestionDTO.IsRequired, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:47), not a property of the model. A [Required] attribute here would apply to every question at once and would be wrong for most of them, so required stays on the field's own Required="@question.IsRequired" parameter (EventFeedback.razor:61, :73) and only the length rule, which genuinely is per-value, is declared on the model. Lifting the model to a public file-level type shared by both pages, rather than a private nested holder per page, is what makes one declaration serve both forms.
  • Where it's used: seeded one instance per question in each page's OnInitializedAsync (EventFeedback.razor.cs:114, SessionFeedback.razor.cs:119), populated by PreFillExistingAnswers, read by GetAnswerValue on submit, and reset in place by DeleteAnswerAsync. A separate prototype instance is constructed in each page's OnInitialized purely to build the validation delegate (EventFeedback.razor.cs:69-72, SessionFeedback.razor.cs:69-72): ModelValidation.ForProperty validates the value handed to it rather than the instance, so one prototype serves the whole dynamic list.
  • Caveats / not-in-source: nothing here enforces the length. The attribute is inert until a validator runs it; the enforcement path is the pages' _validateAnswerText delegate on the client and the Conference invariants on the server.

CheckInErrorCodes

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/CheckInErrorCodes.cs:8 · Level 0 · static class

  • What it is: the two stable error codes the self-service QR landing pages branch on, held as constants beside the service that reads them instead of being retyped inside each page.
  • Depends on: nothing (two const string fields). Consumed together with SelfCheckInOutcome<TResult>, which carries the code back from the transport layer.
  • Concept introduced, the error code as a client-facing contract. [Rubric §9, API & Contract Design] assesses whether failures are expressed in a form a client can branch on. HTTP status codes are not enough here and the type doc says why by implication (CheckInErrorCodes.cs:3-7): two of the three refusals arrive as the same 404, so a page choosing its state from the status alone cannot tell "nothing is scheduled in this room" from "this feature is off". A stable machine-readable code in the ProblemDetails body is the only distinguishing signal, and it must never be a human-readable message, because that message is localized and would break the branch in any non-English locale. [Rubric §9, API & Contract Design] covers the placement: one declaration site for the vocabulary means a page never spells a code out inline, so a typo is a compile error rather than a silently unreachable branch.
  • Walkthrough (CheckInErrorCodes.cs:8-18): EventNotPublished = "CheckIns.EventNotPublished" (:11) means the owning event is not published yet, so nothing is being recorded for it. NoCurrentSession = "CheckIns.NoCurrentSession" (:17) means nothing is scheduled in the scanned room at this instant, and the member comment (:13-16) records the security decision folded into it: an unknown room answers the same way by design, so the state never confirms which room identifiers exist.
  • Why it's built this way: the deliberate absence is as informative as the presence. There is no constant for "feature disabled" or "unknown target", because those produce a bare 404 with no code, which both pages treat as their generic unavailable state (RoomCheckIn.razor.cs:70-74, SponsorVisit.razor.cs:73-74). Refusing to name them is what keeps a printed code from confirming which sponsors or rooms exist (ADR-072).
  • Where it's used: read by RoomCheckIn (:72-73) and SponsorVisit (:73) when mapping an outcome onto their page state; produced on the server as literal strings by CheckInProcessor (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:30) and RecordRoomCheckInHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:101), and documented on the endpoint itself (CheckInsController.cs:150).
  • Caveats / not-in-source: the two sides of this contract are separate string literals in separate projects, not one shared constant, because the UI project deliberately does not reference the Application layer. Nothing but a test keeps them equal.

NowNextSessionInfo

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.HappeningNow · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/INowNextService.cs:27 · Level 0 · sealed record

  • What it is: one row of the now-and-next snapshot, a session's identifier, title, room name, and its wall-clock start and end in the event's own time zone.
  • Depends on: the SessionIdentifierType alias (solution-wide global using, see primer §2). Externals: BCL DateTime, plus System.Text.Json name-based binding at the call site.
  • Concept, the locally mirrored wire shape. [Rubric §7, Microservices Readiness] assesses whether a module can be lifted out without dragging a neighbor's assembly along, and [Rubric §9, API & Contract Design] how a payload contract is expressed to its consumers. The bytes on the wire are produced by Conference's NowNextSessionDTO; Engagement redeclares only the fields it renders instead of referencing MMCA.ADC.Conference.Shared for one payload, and JSON binds them by property name. The type doc on the enclosing snapshot records that this is a deliberate mirror and that the Android home-screen widget makes the same call with a mirror of its own (INowNextService.cs:6-9; the widget's private copy is at MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Platforms/Android/NowNextWidgetProvider.cs:130-134). The same tolerant-reader trade-off taught on AttendeeRow applies: nothing at compile time keeps the mirror aligned with the DTO, only the round-trip tests do.
  • Walkthrough (INowNextService.cs:27-32): five positional members, SessionId (line 28, the deep-link target), Title (29), RoomName (30, nullable), StartsAtLocal (31), and EndsAtLocal (32). Two details are load-bearing. RoomName is null whenever the session carries no room id, or carries one the event's room map does not contain, a decision the server makes in a single expression (GetNowNextHandler.cs:78). And the two times are plain DateTime wall clock in the event's zone, not UTC and not DateTimeOffset: the server passes the stored wall-clock values straight through (GetNowNextHandler.cs:79-80) while keeping the UTC instants it computed for its own filtering on its side of the row (:81-82), so no client ever converts a zone.
  • Why it's built this way: glanceable surfaces (a page card, a home-screen widget) should print exactly what the printed schedule says, so the wall-clock value travels as data. The UTC instants stay on the server's side of the contract for callers that do their own math; this mirror deliberately drops them, along with EventId. The slice arrives with ADR-042 Wave 8 (GetNowNextHandler.cs:13).
  • Where it's used: only inside NowNextSnapshot's Now and Next lists; the HappeningNow page holds them in _sessionsNow / _sessionsNext (HappeningNow.razor.cs:49-50) and renders one card per row.
  • Caveats / not-in-source: the mirror is checked by NowNextServiceTests round-tripping a JSON body, not by the compiler, so a renamed server property would deserialize to a default value rather than break the build.

SelfCheckInOutcome<TResult>

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/SelfCheckInOutcome.cs:20 · Level 0 · sealed record (generic)

  • What it is: the return shape of one self-service check-in post, a sponsor booth visit or a room check-in. It carries either the recorded payload or the server's stable error code, so the calling page can pick its state without catching an exception.
  • Depends on: nothing first-party; it is closed over SponsorVisitResultDTO and RoomCheckInResultDTO at the two call sites, and its ErrorCode values come from CheckInErrorCodes. Externals: BCL only, with a where TResult : class constraint (SelfCheckInOutcome.cs:21) so the nullable Value means exactly "absent".
  • Concept introduced, the result envelope for a refusal that is not a fault. [Rubric §1, SOLID] and [Rubric §9, API & Contract Design] both apply: the type doc (SelfCheckInOutcome.cs:3-13) explains that the landing pages must tell three server answers apart (no session running in this room, event not published, and the flat "unavailable" a disabled feature or unknown target produces), that all three arrive as ordinary non-success responses, and that two of them share the same 404 status. A result carrying only a message would force the page to parse localized prose; an exception would force a try/catch around a normal outcome. Carrying the code as data is what makes the page's mapping a switch expression over constants. Note this is deliberately not the framework's Result, which the other four members of the same interface do return: this is a UI-layer transport envelope for one narrow HTTP pattern, so it stays small and local rather than reusing an abstraction whose error vocabulary it does not need.
  • Walkthrough (SelfCheckInOutcome.cs:20-25): two positional members, Value (the recorded outcome, null when the server refused) and ErrorCode (null when the response carried none: a bare feature-gate 404 or an unparseable body, per the param doc at :16-19). One computed member, IsSuccess => Value is not null (:24), so success is derived from the payload rather than tracked as a third field that could disagree with it. Construction is centralized in CheckInService.PostSelfCheckInAsync (CheckInService.cs:123-133): the success branch deserializes TResult and pairs it with a null code (:122-125), the failure branch passes null with whatever ReadErrorCodeAsync extracted from the RFC 9457 problem body (:128, :145).
  • Why it's built this way: the four other members on the same service run through HttpResultExecutor.ExecuteAsync and answer with a framework Result (CheckInService.cs:27-28, :71, :94), because an organizer facing a broken scan wants a typed failure to surface. The two attendee-facing self-service writes must not, because every refusal is a sentence the landing page renders calmly to someone holding a phone in a hallway. The method doc records exactly that split (CheckInService.cs:106-113). Keeping the generic on the payload lets one private helper serve both endpoints with no duplication (ADR-072).
  • Where it's used: returned by ICheckInUIService.RecordSponsorVisitAsync and .RecordRoomCheckInAsync (ICheckInUIService.cs:54, :60), implemented at CheckInService.cs:57 and :62, consumed by SponsorVisit (:59-63, :73) and RoomCheckIn (:56-60, :70-74).

SessionReminder

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.HappeningNow · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/SessionReminderPlanner.cs:15 · Level 0 · sealed record

  • What it is: an immutable value describing one planned on-device reminder for a bookmarked session, a stable notification id, when it should fire, and where a tap takes the user.
  • Depends on: the SessionIdentifierType alias (solution-wide global using, see primer §2). Externals: BCL DateTimeOffset. No first-party types beyond the alias.
  • Concept, the immutable planning DTO. [Rubric §19, State Management] assesses whether client-side state is modeled as explicit, predictable values rather than mutable ad-hoc fields. A sealed record gives value equality and init-only positional members for free (see primer §4), so a planned reminder is a snapshot that cannot drift after SessionReminderPlanner computes it. The type carries no behavior: it is pure data handed from the pure planner to the stateful coordinator.
  • Walkthrough (SessionReminderPlanner.cs:15-21): six positional members, NotificationId (int, line 14), SessionId (15), SessionTitle (16), StartsAtUtc (17), DeliverAt (18), and DeepLinkRoute (19). The doc comment (:3-12) is load-bearing intent: NotificationId is derived deterministically from the session id so rescheduling replaces an existing OS notification instead of stacking a duplicate, DeliverAt is the fire instant (start minus lead, clamped to now), and DeepLinkRoute is the app-relative route published on tap.
  • Why it's built this way: keeping the planned reminder as a value lets the planner stay a pure function and the coordinator diff plans by id (ADR-042 Wave 2, the local-notification dispatcher).
  • Where it's used: produced by SessionReminderPlanner.Plan (SessionReminderPlanner.cs:81-87); consumed by SessionReminderCoordinator, which calls Plan at SessionReminderCoordinator.cs:159 and turns each returned value into a LocalNotificationRequest (:180).

NowNextSnapshot

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.HappeningNow · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/INowNextService.cs:15 · Level 1 · sealed record

  • What it is: the whole "happening now / up next" payload for one event as the Engagement UI consumes it, the event's display name, whether the event is live at the query instant, and two lists of NowNextSessionInfo rows.
  • Depends on: NowNextSessionInfo. Externals: BCL IReadOnlyList<T>.
  • Concept, the two-bucket time projection, computed once on the server. [Rubric §12, Performance & Scalability] assesses whether work sits where it is cheapest, and [Rubric §18, UI Architecture] whether a page renders a prepared view model rather than deriving one. Both buckets are decided by GetNowNextHandler against a single instant taken from an injected TimeProvider (GetNowNextHandler.cs:29), never from the browser clock. Now is every eligible session whose window is half-open around that instant, StartsAtUtc <= utcNow && utcNow < EndsAtUtc (GetNowNextHandler.cs:53), so a session that ends exactly now is already out and the one starting exactly now is already in. Next is not "the next session": it is the batch that shares the earliest future start (:58-66), which is what makes parallel tracks appear together instead of one arbitrary room winning. The empty cases are ordinary, not error states: between sessions Now is empty, after the last session Next is empty (nextStart is null and the list is [], :60-62), and before the first session Now is empty while Next carries the opening batch.
  • Walkthrough (INowNextService.cs:15-19): four positional members, EventName (line 16), IsLive (17), Now (18), and Next (19). IsLive is independent of the two lists: the server compares the same instant against the event's live window from CurrentEventSelector.GetLiveWindowUtc(StartDate, EndDate, TimeZone) (GetNowNextHandler.cs:68-69), so a live event during a coffee break reports IsLive: true with an empty Now. Ordering is fixed server-side too: Now sorts by start then by room name ordinal-ignore-case (:54-55), Next by room name alone (:65) since every row in it shares one start.
  • Why it's built this way: no client can compute either bucket correctly on its own, because eligibility depends on data a public catalog read does not expose (see INowNextService), so the snapshot is shipped precomputed and every surface (page and widget) shows the same answer. The one snapshot type also keeps IsLive next to the lists, which is what lets a caller distinguish "the conference is not running" from "nothing is on right now".
  • Where it's used: carried by the Result that INowNextService.GetAsync returns; the HappeningNow page projects it with result.Value?.Now ?? [] and result.Value?.Next ?? [] (HappeningNow.razor.cs:157-159), so a failed read renders the same empty state as an empty list. Conference's own wire type is NowNextDTO (GetNowNextHandler.cs:71), and the Android widget deserializes a third copy of the shape (NowNextWidgetProvider.cs:127, :132).
  • Caveats / not-in-source: nothing on the record marks how fresh it is. Staleness is bounded by two caches only, the query result cache of 30 seconds (GetNowNextQuery implements IQueryCacheable, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:38) and the endpoint's 60-second NowNextCache output-cache policy (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:232, applied at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:173 and :187). The page fetches the snapshot from its own load path (HappeningNow.razor.cs:157) and has no timer of its own for it, so a long-open tab keeps its first now-and-next lists until something refreshes it.

SessionReminderPlanner

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.HappeningNow · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/SessionReminderPlanner.cs:31 · Level 1 · static class

  • What it is: a pure, side-effect-free planner that turns a set of bookmarked sessions plus an event time zone and a lead time into a list of SessionReminder values. All the date math (wall-clock to UTC, DST edge cases, lead-window clamping) lives here.
  • Depends on: SessionReminder, SessionInfo (the input session shape), EngagementRoutePaths (the tap route), and the SessionIdentifierType alias. Externals: BCL TimeZoneInfo, DateTimeOffset.
  • Concept, the deterministic pure function. [Rubric §14, Testability] assesses whether logic can be exercised without infrastructure. Every input the planner needs is a parameter, including now (SessionReminderPlanner.cs:53), the current instant, which the caller supplies rather than the planner reading DateTimeOffset.UtcNow itself. That single choice makes DST behavior, lead-window clamping and skip rules unit-testable with fixed clocks and no OS notification stack. [Rubric §27, Internationalization] also applies: session times are wall-clock local to the event's IANA time zone, never UTC in the DTOs (SessionInfo.StartsAt is a bare DateTime?, ISessionLookupService.cs:12), so correct conversion is a first-class concern here rather than an afterthought.
  • Walkthrough:
    • NotificationIdSeed (:34) is a private constant offset that keeps reminder ids clear of any other local-notification ids the app might use. The comment above it (:31-33) states the assumption the whole replace-by-id contract rests on: SessionIdentifierType hash codes are stable for value types.
    • GetNotificationId(sessionId) (:37-38) shifts the seed 16 bits and XORs the session id's hash unchecked, producing that stable id.
    • Plan(bookmarkedSessions, timeZoneId, leadTime, now) (:47-89) guards its arguments (:53-54), resolves the TimeZoneInfo (:56), then per session skips those with no start time (:61-64) or already started (:67-70), computes deliverAt = startsAtUtc - leadTime and, when that instant has already passed, fires an immediate reminder 30 seconds out rather than dropping it (:72-77), and finally emits a SessionReminder whose tap route comes from EngagementRoutePaths.SessionDetails (:79-85, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:12).
    • ToUtc(localWallClock, timeZone) (:91-104) is the DST-correct converter: it marks the wall time Unspecified (:93), shifts a spring-forward invalid time forward one hour (:96-99), and lets GetUtcOffset resolve ambiguous fall-back times to the standard offset (:101-103).
  • Why it's built this way: separating planning (pure, here) from scheduling (I/O, in the coordinator) means the tricky calendar logic is provable in isolation and the coordinator stays a thin best-effort orchestrator (ADR-042 Wave 2, named in the type doc at :22).
  • Where it's used: called by SessionReminderCoordinator.ReplanAsync (SessionReminderCoordinator.cs:159); GetNotificationId is also called there to cancel reminders for sessions that left the tracked set (:99), to cancel everything when reminders are switched off (:118), and to sweep the ids that dropped out of the plan (:170). Covered by SessionReminderPlannerTests.

ICheckInUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/ICheckInUIService.cs:20 · Level 3 · interface

  • What it is: the single UI contract for every QR badge check-in surface: the attendee's own badge, the organizer scan and manual fallback writes, the two attendee self-service scans, and the organizer attendance rollup.
  • Depends on: MyBadgeDTO, CheckInAttendeeRequest, ManualCheckInRequest, CheckInResultDTO, SelfCheckInOutcome<TResult>, SponsorVisitResultDTO, RoomCheckInResultDTO, AttendanceStatsDTO, Result, and the SponsorIdentifierType / RoomIdentifierType / EventIdentifierType aliases. The UI service abstraction itself is taught on IBookmarkUIService.
  • Concept introduced, two error contracts in one interface. [Rubric §9, API & Contract Design] assesses whether a contract's failure semantics are legible from its signatures, and this interface is the clearest example in the module because it deliberately carries two. Four methods return a Result, the ordinary shape for this module: a refusal is a typed failure the page renders. Two methods return SelfCheckInOutcome<TResult> instead, because those two are the endpoints an attendee reaches by scanning a printed code, and every refusal there has to be told apart by a stable error CODE and rendered as a calm sentence. The type doc states exactly that split (ICheckInUIService.cs:13-18). The class doc on CheckInService (CheckInService.cs:16-19) states the other axis: every call is authenticated, the badge read is caller-scoped ("my badge"), and the three organizer calls are role-gated server-side, so [Rubric §11, Security] lives on the server, not in this contract.
  • Walkthrough (ICheckInUIService.cs:20-76):
    • GetMyBadgeAsync(ct) (:23) fetches the signed-in attendee's own badge credential; an attendee with no badge answers ErrorType.NotFound, which the badge page renders as its empty state (:18-21).
    • CheckInByCredentialAsync(request, ct) (:30-32) checks an attendee in from a scanned or typed badge payload; organizer only.
    • ManualCheckInAsync(request, ct) (:40-42) is the fallback used when a badge cannot be scanned; organizer only. Both post through the shared PostCheckInAsync (CheckInService.cs:98), whose doc (:80-84) records that a repeat check-in is reported through AlreadyCheckedIn rather than as a failure, so there is no conflict branch to interpret.
    • RecordSponsorVisitAsync(sponsorId, ct) (:50-52) records the caller's visit to one sponsor booth; the doc states a repeat visit is a success carrying AlreadyVisited, not a failure (:44-47).
    • RecordRoomCheckInAsync(roomId, ct) (:60-62) checks the caller into whatever session the scanned room is hosting right now. The doc sentence at :54-57 is the whole design of that surface: the session is never sent, the server resolves it from the room and the configured grace window, which is why a printed room code never expires when the schedule moves.
    • GetAttendanceStatsAsync(eventId, ct) (:69-71) fetches the attendance rollup for one event; organizer only (CheckInService.cs:75-82).
  • Why it's built this way: one interface rather than one per surface keeps the whole check-in vocabulary, and the single checkins resource it maps to (CheckInService.cs:24), in one reviewable place; the six methods correspond one-for-one to the six endpoints on CheckInsController (ADR-071 for the scan surfaces, ADR-072 for the check-in and points model).
  • Where it's used: implemented by CheckInService (CheckInService.cs:22), registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:47), and injected into five pages: CheckInScan (CheckInScan.razor.cs:26), MyBadge (MyBadge.razor.cs:17), OrganizerAttendance (OrganizerAttendance.razor.cs:19), RoomCheckIn (RoomCheckIn.razor.cs:25) and SponsorVisit (SponsorVisit.razor.cs:26). Server-side the whole controller sits behind [FeatureGate(EngagementFeatures.CheckIn)] (CheckInsController.cs:37) with the two self-service writes gated again individually (:130, :161), see EngagementFeatures and ADR-031.

INowNextService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.HappeningNow · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/INowNextService.cs:40 · Level 3 · interface

  • What it is: the UI-side contract for reading one event's now-and-next snapshot, a single method returning a Result over a NowNextSnapshot.
  • Depends on: NowNextSnapshot, Result, and the EventIdentifierType alias. Externals: BCL Task, CancellationToken.
  • Concept, a port over a server-owned computation. [Rubric §18, UI Architecture] assesses whether components depend on typed contracts rather than transport, and [Rubric §7, Microservices Readiness] whether cross-module reads travel through an explicit boundary. The interface doc (INowNextService.cs:34-39) is the contract worth memorizing, because it enumerates what the server owns and the client therefore must not re-derive: session eligibility (scheduled, non-service, not declined or cancelled), the event-local wall-clock conversion, and the "next = the batch sharing the earliest future start" rule. Concretely, eligibility is CalendarExportMapper.IsExportable (GetNowNextHandler.cs:48). None of that is visible in a public catalog listing, which is precisely why the page cannot compute now-and-next itself; the implementation's doc records that this endpoint replaces the old whole-catalog fetch on Happening Now (NowNextService.cs:8-12).
  • Walkthrough (INowNextService.cs:49-51): one method, GetAsync(eventId, cancellationToken = default), returning Task<Result<NowNextSnapshot>>. The failure contract is the doc at :42-46: an event that is not found or not published answers an ErrorType.NotFound failure, which the page renders as "nothing scheduled". The server produces it as Result.Failure carrying Error.NotFound when the event is missing or IsPublished is false (GetNowNextHandler.cs:32-36), the API surfaces it as a 404, and ProblemDetailsResultReader turns it back into a typed failure in the client (NowNextService.cs:29-31). Answering with a failure rather than throwing is what lets a caller degrade to an empty schedule with no try/catch of its own.
  • Why it's built this way: the one-method port is the DI swap point (services.AddScoped<INowNextService, NowNextService>(), MMCA.ADC.Engagement.UI/DependencyInjection.cs:60) and the test extension point, so the page can be exercised against a fake snapshot with no HTTP stack; it also keeps Engagement free of any compile-time reference to Conference for this read (ADR-007 and ADR-008 put the module in its own process, so this crosses a real service boundary over public HTTP; the implementation uses the plain "APIClient" factory client with no token, NowNextService.cs:23, because the endpoint is anonymous).
  • Where it's used: injected into the HappeningNow page (HappeningNow.razor.cs:28) and called from its load path (:156). Implemented by NowNextService (NowNextService.cs:14); covered by NowNextServiceTests.
  • Caveats / not-in-source: the contract exposes only the per-event form. The endpoint also serves an id-less form where the server auto-selects the current-or-next published event, but no first-party C# client for it exists in this UI; the Android widget calls Events/now-next with a bare HttpClient instead (NowNextWidgetProvider.cs:119-120). Note also a wording gap: the doc comment says "not declined or cancelled" while the code's actual gate is the calendar-export allow-list above.

CheckInService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/CheckIns/CheckInService.cs:20 · Level 4 · sealed class

  • What it is: the single HTTP client for every badge and scan surface in the module, implementing ICheckInUIService against the checkins resource (CheckInService.cs:24). It carries six calls with three different audiences: the attendee reading their own badge, the organizer scanning or manually checking someone in, and the attendee self-recording a sponsor booth or room visit.
  • Depends on: ICheckInUIService, AuthenticatedServiceBase, ITokenStorageService, HttpResultExecutor, ProblemDetailsResultReader, MyBadgeDTO, CheckInAttendeeRequest, ManualCheckInRequest, CheckInResultDTO, SponsorVisitRequest, SponsorVisitResultDTO, RoomCheckInRequest, RoomCheckInResultDTO, AttendanceStatsDTO, SelfCheckInOutcome<TResult>. Externals: IHttpClientFactory, System.Text.Json (JsonDocument), System.Net.Http.Json, System.Globalization.
  • Concept introduced, two failure dialects in one client. The authenticated-HTTP shape is taught at BookmarkService; what is new here is that this class deliberately reports failure two different ways depending on who is looking at the screen. [Rubric §9, API & Contract Design] assesses whether a client contract preserves the distinctions the server actually makes. The four organizer and badge calls answer with a Result, carrying the server's own error code and type. The two attendee self-service calls do not: they return a SelfCheckInOutcome<TResult> carrying only a raw error-code string, because the QR landing pages have to tell "no session is running in this room" from "the owning event is not published" and both arrive as HTTP 404 (CheckInService.cs:115-122, and the outcome record's own doc at SelfCheckInOutcome.cs:5-12). A status code alone cannot pick the state, so the code is read out of the problem body and handed to the page. [Rubric §26, Front-End Security] is the reason the pages then collapse several of those codes into one message: see CheckInState and VisitState.
  • Walkthrough (public calls first, then the two private posters)
    • GetMyBadgeAsync (:23-38) GETs checkins/my-badge. The comment (:33-35) records the contract precisely: a 404 arrives as a NotFound failure rather than the old null, so "this attendee has no badge" stays a distinguishable state while the badge page renders exactly the empty surface it rendered for null. The server mints a badge on first use, so this read is a create-or-get on the other side (CheckInsController.cs:50).
    • CheckInByCredentialAsync (:41-44) and ManualCheckInAsync (:47-50) are one-line delegations to PostCheckInAsync, differing only in the relative URL: the root checkins and checkins/manual. Both server endpoints are permission-gated on EngagementPermissions.CheckInManage (CheckInsController.cs:79, :99), so the client does no role check of its own.
    • RecordSponsorVisitAsync (:53-59) and RecordRoomCheckInAsync (:62-68) build their one-field requests inline (SponsorVisitRequest, RoomCheckInRequest) and delegate to PostSelfCheckInAsync. Neither call passes an attendee identity: the server takes it from the token, so a scanned code can only ever check in the person holding the phone. Each also has its own feature gate on the server (CheckInsController.cs:134, :161).
    • GetAttendanceStatsAsync (:71-87) GETs checkins/stats?eventId= with invariant formatting (:79) and returns the AttendanceStatsDTO rollup; the endpoint is permission-gated the same way as the writes (CheckInsController.cs:183).
    • PostCheckInAsync<TRequest> (:94-109) is the Result-returning poster shared by the two organizer writes. Its doc comment (:89-93) records why there is no conflict branch: a repeat check-in comes back with AlreadyCheckedIn set rather than as a 409, so the caller has one success shape to interpret.
    • PostSelfCheckInAsync<TRequest, TResult> (:119-138) is the outcome-returning poster, and notably the one method in the class that does not go through HttpResultExecutor or the reader. On success it reads the payload with ReadFromJsonAsync and wraps it (:131-135); on any non-success it returns new SelfCheckInOutcome<TResult>(null, await ReadErrorCodeAsync(...)) (:137), so a refusal is data, not an exception.
    • ReadErrorCodeAsync (:145-180) walks the problem body's errors array and returns the first element's code string (:156-170). Everything else yields null: an empty body (:152-153), a body with no errors array, a non-string code, or a JsonException from an HTML error page or a bare challenge (:174-179, with the comment naming that case). null is a meaningful value here, it is what the pages render as the generic "unavailable" state.
  • Why it's built this way: the class doc (:12-14) sets the rule that every call is authenticated, including the badge read, because the badge is caller-scoped rather than public (CheckInsController.cs:38). The split between the two private posters is the client-side consequence of a server design decision recorded in ADR-072: organizer scanning and attendee self-service produce the same domain writes but face very different humans, so the refusal vocabulary is stable (CheckInErrorCodes) and the client passes it through untranslated.
  • Where it's used: registered scoped as ICheckInUIService (MMCA.ADC.Engagement.UI/DependencyInjection.cs:47) and injected by five surfaces: MyBadge (MyBadge.razor.cs:17, called at :49), CheckInScan (CheckInScan.razor.cs:26, called at :163, :186), OrganizerAttendance (OrganizerAttendance.razor.cs:19, called at :51), RoomCheckIn (RoomCheckIn.razor.cs:25, called at :56) and SponsorVisit (SponsorVisit.razor.cs:26, called at :59). The whole controller sits behind the EngagementFeatures.CheckIn gate (CheckInsController.cs:37), so a deployment with the feature off answers 404 to every one of these calls.
  • Caveats / not-in-source: CheckInServiceTests covers only the self-service path, two facts on RecordSponsorVisitAsync asserting that the response is disposed on both the success (:30) and the failure (:58) branch, the latter also checking the error code is read. The other four calls are exercised indirectly through the bUnit page tests (MyBadgeTests, CheckInScanTests, OrganizerAttendanceTests, RoomCheckInTests, SponsorVisitTests), which mock the interface rather than the transport. Which error codes a given server build emits is the API's contract, not this file's.

NowNextService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.HappeningNow · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/NowNextService.cs:14 · Level 4 · sealed class

  • What it is: the HTTP implementation of INowNextService, one GET against the Conference API's anonymous Events/{id}/now-next endpoint, read into a NowNextSnapshot and handed back untouched.
  • Depends on: INowNextService, NowNextSnapshot, HttpResultExecutor, ProblemDetailsResultReader. Externals: IHttpClientFactory, HttpClient, CultureInfo.
  • Concept introduced, the thin lookup service (the contrast with the authenticated services above). [Rubric §18, UI Architecture] and [Rubric §12, Performance & Scalability]. Unlike BookmarkService this class does not derive from AuthenticatedServiceBase: it takes the named "APIClient" from the factory directly (NowNextService.cs:23), the same shape as the module's other read-only lookups (SessionLookupService). That named client is registered once in the framework (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:85) with the gateway base address plus the auth and culture delegating handlers, so a bearer token still rides along when the user has one, but the endpoint is [AllowAnonymous] (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:172) and an anonymous visitor gets the same payload. The consequence to notice: no token fetch and no RetryPolicy wrap this call (:25-27 calls GetAsync directly), because there is nothing user-specific to authorize and the answer is cheap to re-request.
  • Walkthrough (NowNextService.cs:17-33), which is mostly guards
    • The whole body runs inside HttpResultExecutor.ExecuteAsync (:20), so a refused connection or a broken socket becomes a Http.TransportFailure failure rather than an exception the page has to catch.
    • Both disposables are scoped with using, the client (:23) and the response (:25), so a per-call fetch leaks neither.
    • The relative URI is built with string.Create(CultureInfo.InvariantCulture, $"Events/{eventId}/now-next") (:26). Invariant formatting is not decoration: the id is interpolated into a path, and a culture-sensitive numeric format would produce a path the API cannot route.
    • The response goes through ProblemDetailsResultReader.ReadAsync<NowNextSnapshot> (:31), so 404 becomes a NotFound failure, with the comment naming the case it covers (an unpublished or deleted event) and the consequence: the page shows nothing scheduled, exactly as it did for the old null (:29-30). A 5xx becomes a failure carrying the server's status code instead, so the page can tell "no such event" from "the service is broken" by reading the error rather than by catching an exception.
    • The CancellationToken is threaded through the GET (:27), the read (:31) and the executor (:32), so page disposal cancels an in-flight fetch and the cancellation propagates rather than being reported as an error.
    • There is no client-side filtering, sorting or time math anywhere in the method: the lists arrive ordered and filtered and are passed straight to the page.
  • Why it's built this way: the class doc (:8-13) states the rationale directly, the endpoint already excludes service, declined and cancelled sessions and does the event-local time math, so nothing is recomputed here. A whole-catalog fetch plus client-side time comparison could not tell an ineligible session apart, since eligibility is not part of a public catalog listing. The call is also cheap to repeat: the server output-caches it under the NowNextCache policy (EventsController.cs:173) and the payload is identical for every role, so no per-user cache key is needed.
  • Where it's used: registered scoped as INowNextService in the module's UI wiring (MMCA.ADC.Engagement.UI/DependencyInjection.cs:60); consumed only by the HappeningNow page today (HappeningNow.razor.cs:28, called at :156). [Rubric §14, Testability]: covered by three facts in NowNextServiceTests, the exact request path (:20), the unchanged projection of the server snapshot (:33), and the 404-to-NotFound-failure degradation (:50).
  • Caveats / not-in-source: no timeout, retry or circuit breaker is configured on this path in the framework registration of the named client. A transient failure is therefore one transport failure and one localized error message, with a page reload as the only retry. Note also that the Conference API exposes a second, id-less form of the same snapshot for the home-screen widget (EventsController.cs:186); this service does not call it.

SessionReminderCoordinator

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.HappeningNow · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/SessionReminderCoordinator.cs:20 · Level 4 · sealed partial class

  • What it is: the piece that keeps on-device session reminders in step with the user's bookmarks (ADR-042 Wave 2). It owns a tracked session set and two settings in device preferences, and on every change it replans the whole schedule and cancel-then-reschedules the difference (class doc, :8-15).
  • Depends on: ILocalNotificationService, IDevicePreferences, ISessionLookupService, ILiveEventUIService, SessionReminderPlanner, LocalNotificationRequest, LiveEventContext, SessionInfo, SessionReminder. Externals: IStringLocalizer<T>, ILogger<T> with a source-generated [LoggerMessage].
  • Concept introduced, best-effort derived state behind a capability gate. Two ideas meet here. First, [Rubric §19, State Management]: a scheduled local notification is derived state that only stays correct if it tracks the bookmark set, and the bookmark set changes from any page and any device. This class is the one place that derivation lives. Second, [Rubric §29, Resilience & Business Continuity]: reminders are a convenience and must never fail a bookmark operation, so every entry point is best-effort. MutateTrackedAsync (:107-110) and ApplySettingsAsync (:69-72) both wrap their work in catch (Exception ex) when (ex is not OperationCanceledException) and log a warning through LogReminderSyncFailed (:190-191), rather than propagating. Note the exception filter: a genuine cancellation is not swallowed. Third, and this is what makes the coupling cheap, every entry point begins with if (!notifications.IsSupported) return; (:57-60, :88-91), so on a web head the whole class is a no-op that touches no storage and issues no calls. The [LoggerMessage] source generator is the framework's structured-logging default, [Rubric §13, Observability & Operability]: a failure here is invisible to the user by design, so a log entry is the only signal that reminders may be stale.
  • Walkthrough (constants, then the four entry points, then the two private engines)
    • The preference keys and defaults are public consts: EnabledKey (:26), LeadMinutesKey (:29) and DefaultLeadMinutes = 15 (:34), with the tracked-set key kept private (:31). IsSupported (:37) forwards the capability flag so a settings page can hide the section entirely.
    • NotifyBookmarkAddedAsync (:40-41) and NotifyBookmarkRemovedAsync (:44-45) are one-liners over MutateTrackedAsync, appending with Distinct() and filtering with the default equality comparer for the identifier alias respectively.
    • ResyncAsync (:51-52) replaces the tracked set outright with an authoritative bookmark map. Its doc (:47-50) names the reason it exists: a page that loads the user's bookmarks has the truth in hand, so this is the moment to heal drift caused by changes made on another device.
    • ApplySettingsAsync (:55-73) persists both settings then replans, which is also how disabling works: ReplanAsync cancels everything when enabled is false.
    • GetSettingsAsync (:76-81) reads the pair back for the device-settings UI, defaulting to enabled and 15 minutes.
    • MutateTrackedAsync (:83-111) is the shared write path: read the tracked array, apply the caller's mutation, persist it (:94-96), cancel reminders for sessions that left the set (:99-103), then replan the rest. Cancelling before replanning is deliberate and the comment says so (:98).
    • ReplanAsync (:113-188) is the heart of the class and reads as a sequence of early returns, each protecting the existing schedule: reminders switched off cancels everything tracked and returns (:115-125); a denied OS permission returns without scheduling, and the comment records that the prompt appears at most once and a denial leaves reminders silently off until the user grants them in system settings (:127-132); no live event returns (:134-138); a catalog read the API refuses returns, with the comment giving the reason, replanning against an empty catalog would cancel reminders that are still valid (:142-148). Note the shape of that last guard: sessionLookup.GetAllAsync returns a Result and the code branches on TryGetValue (:145), so "the API said no" and "there are no sessions" are different outcomes.
    • Past the guards it narrows the tracked ids to sessions in the current event (:150-154), calls SessionReminderPlanner.Plan with the event's IANA zone, the lead time and DateTimeOffset.UtcNow (:156-160), then does the stale sweep (:169-176): the tracked ids minus the planned ids, cancelled. The comment there is the best single paragraph in the file (:161-168), it names the four ways a session can stay tracked yet drop out of the plan (deleted, moved to another event, times cleared per BR-16, start moved into the past), notes that nothing upstream cancels those, and explains why the sweep sits after a plan was computed rather than beside the early returns: cancelling on a transient null live event would wipe reminders that are still valid.
    • Finally it schedules each planned reminder as a LocalNotificationRequest with a localized title and body (:178-187). The body takes the session title and the lead minutes as format arguments (:183), so the wording is resource-driven rather than concatenated. [Rubric §27, i18n].
  • Why it's built this way: ADR-042 puts device capabilities behind interfaces the web head can answer "unsupported" to, which is what lets this class be registered unconditionally (MMCA.ADC.Engagement.UI/DependencyInjection.cs:38, whose comment repeats the no-op guarantee) and injected into a service every head uses. Notification ids are derived deterministically from the session id (SessionReminderPlanner.GetNotificationId, SessionReminderPlanner.cs:39-40), which is what makes rescheduling replace instead of duplicate, and is why a replan-everything strategy is affordable.
  • Where it's used: injected as a concrete class by SessionBookmarkUIService (SessionBookmarkUIService.cs:29), which drives all three notify or resync members, and by the host's device-settings page (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/DeviceSettings.razor:4), which reads the settings (:67), writes them on change (:89, :95) and seeds its lead-time control from DefaultLeadMinutes (:61). [Rubric §14, Testability]: SessionReminderCoordinatorTests covers twelve facts, one per branch above, including the two stale-sweep cases (:129, :155), that no live event cancels nothing (:176), that an unsupported host touches nothing (:223), that lookup failures never throw out of the coordinator (:237), and that a failed catalog read leaves the existing schedule alone (:249).
  • Caveats / not-in-source: IsSupported (:37) has no consumer in the repo today, including the device-settings page its doc points at; the page relies on the internal no-ops instead. The class is also registered scoped while the state it manages is device-global: correctness rests on preferences being the single source of truth and on every mutation replanning from scratch, not on any one instance living long. Whether a scheduled notification actually fires is the platform's business, outside this file.

SessionFeedback

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.Feedback · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/SessionFeedback.razor.cs:18 · Level 6 · class (partial)

  • What it is: the code-behind for the attendee session-feedback page (@page "/feedback/session/{SessionId}", SessionFeedback.razor:1). It checks three business preconditions before it will render the question form at all, then loads the dynamic question set, pre-fills any answers the attendee already submitted, and posts the whole form back in one atomic call.
  • Depends on: IQuestionLookupService, ISessionFeedbackUIService, IEntityService<TEntityDTO, TIdentifierType> (bound as IEntityService<SessionDTO, SessionIdentifierType>, SessionFeedback.razor.cs:22), IToastService, SessionDTO, QuestionDTO, SessionQuestionAnswerDTO, FeedbackAnswerModel, ModelValidation and DataAnnotationsModelValidator, plus the Parse<T> extension on DomainHelper and the IsNotFound() result extension from MMCA.Common.UI.Common. Externals: Blazor NavigationManager, MudBlazor MudForm / BreadcrumbItem, System.Globalization, and from the markup an injected IStringLocalizer<SessionFeedback> L (SessionFeedback.razor:6), the shared UnsavedChangesGuard component (SessionFeedback.razor:10-13) and ErrorSummary (SessionFeedback.razor:106-107).
  • Concept introduced, the precondition-gated dynamic form. [Rubric §18, UI Architecture] assesses component/logic separation: the markup lives in SessionFeedback.razor and the behavior in this partial class, so the page's flow is reviewable apart from its layout. [Rubric §24, Forms, Validation & UX Safety] is the load-bearing category here and it shows up three times. First, CheckPreconditions (SessionFeedback.razor.cs:141) refuses before rendering inputs, so when feedback is not permitted the attendee gets one sentence explaining why rather than a form that fails on submit. Second, validation is declarative and single-sourced: _validateAnswerText (:63) is one delegate built from FeedbackAnswerModel's annotations and handed to every free-text field, so the 4000-character rule is never written twice. Third, UnsavedChangesGuard IsDirty="_isDirty" wraps the page, so navigating away with unsaved edits prompts first. The dynamic part is the interesting one: the questions are data, not markup, so the render is a switch over question.QuestionType (SessionFeedback.razor:58-85) that picks a MudRating, an email MudTextField, or a three-line multiline MudTextField, and each field's Required/RequiredError comes from the DTO rather than from the page. [Rubric §19, State Management] covers the _answers working map; [Rubric §27, i18n] covers the fact that every visible string, error messages included, is an L["..."] lookup.
  • Walkthrough (teaching order)
    • Injected services (SessionFeedback.razor.cs:20-24) and the [Parameter] string SessionId (:26) arrive from the route. Note the parameter is a string, not an id alias: the route template carries no :int constraint, so parsing is the page's job.
    • IsLoading / IsSubmitting (:32-33), HasExistingFeedback (:35) and SubmitButtonText (:37-48) drive the chrome: the button reads "Submitting" while a post is in flight, then "Update" or "Submit" depending on whether prior answers exist. The private state fields (:50-57) hold the load error, the precondition message, the session title, the loaded questions, the _answers map, the _existingAnswerIds map, the MudForm reference and the _isDirty flag. MarkDirty (:65) is the one-line handler every input's @bind-...:after calls.
    • OnInitialized (:68-72) builds _validateAnswerText once, by handing a throwaway FeedbackAnswerModel, the model => model.TextValue selector and a DataAnnotationsModelValidator(L) to ModelValidation.ForProperty. The strongly-typed ForProperty overload (MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/ModelValidation.cs:69) is the right one here because these fields have no For expression and the value being validated has not been written to a model yet; the localizer resolves the ErrorMessage resource key (ADR-027).
    • OnInitializedAsync (:74) builds the three-item breadcrumb trail, then loads the session through SessionService.GetByIdAsync(sessionId, false, _cts.Token) (:88). The failure branch (:89-97) splits the two reasons apart with sessionResult.IsNotFound(): a genuine 404 keeps the page's own "session not found" wording, anything else falls to the generic load failure.
    • CheckPreconditions(session) (:101) runs next, and a returned message stops the load before any questions are fetched (:102-106). Only then does it load the "Session" questions ordered by Sort (:109-116), seed one FeedbackAnswerModel per question (:119), and pre-fill from the server (:122-129). Every await passes _cts.Token; OperationCanceledException is swallowed as expected on disposal (:131-134), and IsLoading is cleared in finally (:135-138).
    • CheckPreconditions (:141) encodes three rules, each returning a localized message. BR-91 rejects service sessions (session.IsServiceSession, :144-147). BR-49 rejects sessions whose Status is set and is not "Accepted", compared case-insensitively (:150-154). BR-16 rejects unscheduled sessions, where StartsAt or EndsAt is null (:158-161), because session feedback is a time-gated action. Returning null means feedback is allowed.
    • PreFillExistingAnswers (:166) is defense in depth. Because feedback questions are shared across sessions, an answer belonging to a different session would still match by QuestionId, so the loop skips any answer whose SessionId does not equal the current one (:172-175) before mapping a rating (culture-invariant int.TryParse, :184-187) or a text value onto the model. It also records the server-assigned answer id in _existingAnswerIds (:182), which is what enables the per-question clear button.
    • SubmitFeedbackAsync (:196) validates the form and toasts a warning if it is invalid (:198-206), then projects the answered questions into a pending list of (QuestionId, AnswerValue) tuples, dropping blanks (:216-220). An empty list short-circuits: with nothing answered there is no form to send, so the submit stays a local no-op rather than a request the batch endpoint would refuse (:222-232). Otherwise it makes one call, FeedbackService.SubmitAnswersAsync(sessionId, pending, _cts.Token) (:226), which the interface documents as applied atomically server-side (IFeedbackUIService.cs:57-64). A failure therefore wrote nothing: the page toasts an error and returns with _isDirty still set, so the same Submit button retries. On success it clears _isDirty, toasts the count, then calls StateHasChanged() before NavigateBack() (:237-238); the inline comment (:236) explains why: the flush lets UnsavedChangesGuard's IsDirty parameter update before NavigateTo fires the LocationChanging handler, otherwise the guard would prompt over changes that were just saved.
    • DeleteAnswerAsync (:250) clears one previously-saved answer: it looks the answer id up in _existingAnswerIds, calls FeedbackService.DeleteAnswerAsync, removes the entry, resets the model's two members (:266-271), and clears _isDirty because the delete already committed server-side (:273-275).
    • ParseSessionId (:284-285) converts the route string with SessionId.Parse<SessionIdentifierType>(), the DomainHelper extension, rather than a hand-rolled parse. NavigateBack (:287-288) returns to the Conference session detail route. GetAnswerValue (:314) is the static bridge from a FeedbackAnswerModel back to a wire string, treating RatingValue > 0 as the only answered rating (:321-323).
    • Dispose(bool) / Dispose() (:292-312) implement the standard disposable pattern, cancelling and disposing _cts so in-flight loads stop when the attendee leaves.
  • Why it's built this way: pushing the three business rules into one CheckPreconditions method keeps the render path binary (either a precondition message or the form) and makes each BR individually reviewable and individually testable. The single batch submit is the more consequential choice, and it is the one place this page diverges from EventFeedback: because the server applies a whole session form atomically, there is no partially-saved state to report to the attendee, so the failure path is a plain retry rather than a "3 of 5 saved" message. The cancellation-token discipline on every await and the StateHasChanged-before-navigate ordering are correctness, not style.
  • Where it's used: rendered at /feedback/session/{SessionId}, reached from the public session detail page's SubmitFeedback (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sessions/PublicSessionDetail.razor.cs:337). The same route is also expressed as EngagementRoutePaths.SessionFeedback(sessionId) (EngagementRoutePaths.cs:18). Its dependencies are registered by the Engagement UI DependencyInjection. Component-tested by SessionFeedbackTests.
  • Caveats / not-in-source: the markup, the resource strings and UnsavedChangesGuard's own behavior live outside this file. The BR numbers in the comments are the page's own claim about which business rule it implements; the authoritative statements live in the ADC specifications guide, not in this file.

EventFeedback

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.Feedback · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/EventFeedback.razor.cs:19 · Level 8 · class (partial)

  • What it is: the code-behind for the attendee event-feedback page (@page "/feedback/event/{EventId}", EventFeedback.razor:1). It loads the event name and its dynamic question set, pre-fills any answers the attendee already submitted, and persists each answer as a POST-as-upsert (BR-107, per the class doc comment at EventFeedback.razor.cs:15-18).
  • Depends on: IQuestionLookupService, IEventFeedbackUIService, IEventLookupService, IToastService, QuestionDTO, EventQuestionAnswerDTO, FeedbackAnswerModel, ModelValidation, DataAnnotationsModelValidator and DomainHelper (the Parse<T> extension). Externals: Blazor NavigationManager, MudBlazor MudForm / BreadcrumbItem, System.Globalization, and from the markup an injected IStringLocalizer<EventFeedback> L (EventFeedback.razor:6) plus the shared UnsavedChangesGuard (EventFeedback.razor:10-13) and ErrorSummary (EventFeedback.razor:95-96) components.
  • Concept: the dynamic feedback form (the _answers map, the single validation delegate, the UnsavedChangesGuard pairing and the StateHasChanged-before-navigate ordering) is taught on SessionFeedback and is identical here. What distinguishes this page is the submit strategy, and it is a genuine behavioral difference rather than a copy: IEventFeedbackUIService exposes only a per-question SubmitAnswerAsync (IFeedbackUIService.cs:26), with no batch counterpart, so the page posts a loop and must be able to report a partial save. [Rubric §24, Forms, Validation & UX Safety] assesses exactly that: a form that can half-succeed owes the user an honest count and a safe retry.
  • Walkthrough of what differs from SessionFeedback
    • There is no precondition gate and no _preconditionMessage: an event either loads or it does not. OnInitializedAsync (EventFeedback.razor.cs:74) resolves the event name from EventLookup.GetAllAsync (:86), setting a localized _loadError when the lookup itself fails (:88) or when the id is absent from it (:99). Everything after that (questions ordered by Sort at :111, one FeedbackAnswerModel per question at :114, the pre-fill at :117-124) matches the session page step for step.
    • PreFillExistingAnswers (:136) applies the same cross-entity guard, skipping answers whose EventId does not match the current event (:142-145).
    • SubmitFeedbackAsync (:166) validates the form (:168-176), projects the answered questions into pending dropping blanks (:186-189), then walks that list one upsert at a time (:192-202). The loop is deliberately failure aware: it counts successes in submitted, and on the first failure toasts Snackbar.PartialSubmit with both submitted and pending.Count (:197) and returns while leaving _isDirty set. That is safe precisely because the endpoint is an upsert: pressing Submit again re-sends the already-saved answers as no-ops and retries the rest. Compare the session page, which gets an all-or-nothing guarantee from the server and therefore has nothing partial to report.
    • ParseEventId (:253-254) uses EventId.Parse<EventIdentifierType>(); NavigateBack (:256-257) returns to the Conference event detail route.
    • DeleteAnswerAsync (:220), GetAnswerValue (:283) and the disposable pattern (:261-281) are the session page's, unchanged in mechanism.
  • Why it's built this way: the page owns orchestration only, all I/O goes through injected UI service interfaces, which keeps it testable under bUnit and swappable per host. The per-question loop is not a preference: it is the shape the event feedback contract offers, and the partial-submit message is the page being truthful about what that contract can guarantee. Sharing the rest of the form shape with SessionFeedback by convention rather than through a base class keeps each page self-contained and independently editable, which is what let the session page adopt a batch endpoint without touching this one.
  • Where it's used: rendered at /feedback/event/{EventId}, reached from the public event detail page's SubmitFeedback (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Events/PublicEventDetail.razor.cs:177). The same route is also expressed as EngagementRoutePaths.EventFeedback(eventId) (EngagementRoutePaths.cs:17). Its dependencies are registered by the Engagement UI DependencyInjection. Component-tested by EventFeedbackTests.
  • Caveats / not-in-source: the markup, the resource strings and UnsavedChangesGuard's own behavior live outside this file; this section covers the code-behind plus the bindings its members exist to serve.

AttendeeRow

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/AttendeeLookupService.cs:184 · Level 0 · record (private sealed, nested)

  • What it is: the wire shape of one row returned by the Identity /Users endpoint, declared privately inside AttendeeLookupService purely so System.Text.Json has something to bind to. It never leaves the service: every row is projected into the public AttendeeSummary before it reaches a caller.
  • Depends on: the UserIdentifierType alias (solution-wide global using, see primer §2). Externals: System.Text.Json binding at the call site, wrapped in PagedCollectionResult<T>.
  • Concept introduced, the private deserialization shape. [Rubric §9, API & Contract Design] assesses how a consumer expresses the contract it reads. Two types for one payload looks redundant until you read what the private one is allowed to be: the doc comment (AttendeeLookupService.cs:180-183) records that only the fields the check-in surfaces render are read and that the device and audit fields the endpoint may carry are ignored, which is exactly the tolerant-reader posture. [Rubric §7, Microservices Readiness] is the other half: Engagement redeclares five fields instead of referencing the Identity assembly for its list DTO (IAttendeeLookupService.cs:6-9 states that constraint for the public twin), so Identity can move, be versioned, or add columns without a compile-time edit here.
  • Walkthrough (AttendeeLookupService.cs:184-189): five positional members, UserId, Email, FirstName, LastName, Role, structurally identical to AttendeeSummary minus its computed DisplayName. It is bound as PagedCollectionResult<AttendeeRow> (AttendeeLookupService.cs:83-84) so one read picks up both the page items and the pagination metadata, and converted one row at a time by the private ToSummary (:177-178).
  • Why it's built this way: private sealed keeps the wire shape from becoming an accidental public contract. The projection point is the single place where a server rename would have to be absorbed, and the computed display fallback lives on the public record rather than on the row, so the transport type stays inert data.
  • Where it's used: only inside AttendeeLookupService, by SearchAsync (:50, :83-88) and, through it, by the roster snapshot LoadRosterAsync builds (:143-175).
  • Caveats / not-in-source: nothing at compile time keeps this shape aligned with what Identity actually serializes. A renamed server property binds to the member default rather than failing the build; only AttendeeLookupServiceTests round-tripping a JSON body catches it.

AttendeeSearchField

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/IAttendeeLookupService.cs:41 · Level 0 · enum

  • What it is: which one of last name, first name or email the organizer's single search box applies to when looking an attendee up at the door.
  • Depends on: nothing (a bare enum).
  • Concept introduced, the enum that exists because of a server semantic. [Rubric §9, API & Contract Design] assesses whether a client contract makes the server's actual behavior usable. The type doc states the reason outright (IAttendeeLookupService.cs:36-40): the Identity users endpoint ANDs its filters, so broadcasting one typed term across all three fields would match nobody. A UI that offered one box and no field picker would therefore be a UI that silently never finds anyone. [Rubric §24, Forms, Validation & UX Safety] covers the resulting shape: one box plus one explicit selector, so the organizer always knows which field the term is being matched against.
  • Walkthrough (IAttendeeLookupService.cs:41-51): three members with explicit values, LastName = 0 (:44), FirstName = 1 (:47), Email = 2 (:50). The explicit ordinals matter because the value is round-tripped as a string through the grid's filter state and parsed back with Enum.TryParse (AttendeeSearchPanel.razor.cs:49-51), which falls back to LastName when the stored value does not parse. LastName is also the field's initial value (AttendeeSearchPanel.razor.cs:36), which is the field an organizer reads off a badge first.
  • Why it's built this way: an enum rather than a raw string keeps the three-way mapping exhaustive and reviewable in one switch (AttendeeSearchPanel.razor.cs:112-118), which projects the selected member onto the matching AttendeeSummary property name and writes a single ("contains", term) filter (:120).
  • Where it's used: only by AttendeeSearchPanel, as the bound value of a MudSelect (AttendeeSearchPanel.razor:15-22) and as the discriminator in its filter builder; the resulting filter is what IAttendeeLookupService.SearchAsync receives as its email / firstName / lastName arguments (IAttendeeLookupService.cs:71-79).

SessionInfo

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/ISessionLookupService.cs:9 · Level 0 · record

  • What it is: the lightweight projection of a Conference session carrying just what Engagement pages need to label a bookmark, a check-in row or a live row: id, title, optional start and end times, and the owning event id. It sits at the top of ISessionLookupService.cs because it is the shape that lookup contract returns.
  • Depends on: the SessionIdentifierType and EventIdentifierType aliases (Conference identifier aliases, solution-wide global using, see primer §2). No first-party service dependencies.
  • Concept introduced, the cross-module display projection. Engagement owns no session table (database-per-service, ADR-006), so it cannot join to a session title: it fetches the fields it needs over HTTP and holds them in this small record, a deliberately thinner shape than Conference's own SessionDTO. [Rubric §9, API & Contract Design] assesses how a consumer models another service's data at its boundary; declaring five fields here means a change to unrelated SessionDTO members never ripples into Engagement. [Rubric §7, Microservices Readiness] is the same decision seen from the extraction side, and it is the identical posture the neighbouring AttendeeSummary takes against Identity: the consumer redeclares the view it renders rather than depending on the producer's assembly.
  • Walkthrough (ISessionLookupService.cs:9-14): five positional members, (SessionIdentifierType Id, string Title, DateTime? StartsAt, DateTime? EndsAt, EventIdentifierType EventId). StartsAt and EndsAt are nullable because a session imported from the schedule source may not yet have times assigned, so every consumer has to tolerate an unscheduled session. It is a plain public record, not sealed.
  • Why it's built this way: a compact record lets SessionLookupService build a whole-catalog dictionary cheaply, and value equality plus init-only members mean a page can hold, compare and sort the rows client-side without a second fetch per row.
  • Where it's used: returned by ISessionLookupService (a catalog keyed by id, or one row), constructed by SessionLookupService from SessionDTO payloads (SessionLookupService.cs:38-39, :64-65); consumed by CheckInScan as its session list (CheckInScan.razor.cs:49), by SessionReminderPlanner as the bookmarked-session input it schedules against (SessionReminderPlanner.cs:50), and by the live-session pages (Pages/SessionLive/SessionLive.razor.cs, Pages/SessionLive/PresenterView.razor.cs).
  • Caveats / not-in-source: nothing here constrains Title to non-empty or the times to a sane order; both come straight off the Conference payload, and the nullable times are the only shape signal that an unscheduled session exists.

ISessionLookupService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/ISessionLookupService.cs:19 · Level 3 · interface

  • What it is: the contract for fetching Conference session data for display enrichment inside Engagement pages, returning SessionInfo projections. Two members: a whole-catalog read and a single-session read, both Result-typed.
  • Depends on: SessionInfo, Result (MMCA.Common.Shared.Abstractions, imported at ISessionLookupService.cs:1), the SessionIdentifierType alias. Externals: BCL IReadOnlyDictionary and Task.
  • Concept introduced, two reads with an explicit efficiency contract, and absence expressed as a typed failure. The doc on GetAllAsync (ISessionLookupService.cs:21-26) is unusually prescriptive: use it "only when a page genuinely needs the whole set (e.g. the bookmark reminder planner, which schedules against every bookmarked session)", while single-session pages "must use GetByIdAsync instead of transferring the catalog to label one row". That is the interface teaching its own performance discipline. [Rubric §12, Performance & Scalability] assesses whether whole-collection transfers are avoided on paths that render one item; the split-method contract encodes the fast path in the type rather than in a review comment. The second idea is the Result posture: GetByIdAsync's doc states that a missing session answers an ErrorType.NotFound failure rather than the old null (:31-32), so callers branch on an ErrorType instead of on a null check that could not distinguish "no such session" from "the fetch broke".
  • Walkthrough
    • GetAllAsync(CancellationToken) (:27-28) returns Result<IReadOnlyDictionary<SessionIdentifierType, SessionInfo>>, the catalog keyed by id, so a page can label many rows with O(1) lookups.
    • GetByIdAsync(SessionIdentifierType, CancellationToken) (:34-36) returns Result<SessionInfo> and fails with NotFound rather than answering an empty value.
  • Why it's built this way: keeping both shapes on one interface lets a page pick the cost that matches its need while the implementation shares one HTTP client and one mapping; typing both on Result means a Conference outage is a renderable state, not an unhandled exception on a bookmark list. Note the contrast with the sibling IAttendeeLookupService, which drops Result on its naming member on purpose: here both reads feed content a page actually renders, so both failures matter.
  • Where it's used: implemented by SessionLookupService, registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:54). Injected by CheckInScan (CheckInScan.razor.cs:28), OrganizerAttendance, the live-session pages, and SessionReminderCoordinator, which is the whole-catalog case the GetAllAsync doc names.
  • Caveats / not-in-source: the "must use GetByIdAsync" rule is a doc comment, not a compiler or test constraint; nothing fails the build if a single-session page calls GetAllAsync.

QuestionLookupService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/QuestionLookupService.cs:13 · Level 4 · sealed class

  • What it is: the HTTP implementation of IQuestionLookupService. It fetches the user-authored feedback questions for one entity type ("Event" or "Session") from the Conference-owned questions resource.
  • Depends on: IQuestionLookupService, AuthenticatedServiceBase, ITokenStorageService, HttpResultExecutor, ProblemDetailsResultReader, QuestionDTO, PagedCollectionResult<T>. Externals: IHttpClientFactory.
  • Concept: the same authenticated-HTTP shape as BookmarkService, used here to reach a Conference-owned read endpoint from the Engagement UI. [Rubric §7, Microservices Readiness] assesses whether a module reaches across a boundary through a contract that survives extraction: this one already does, because the call is plain HTTP through the Gateway rather than a project reference into Conference. The Engagement UI references only QuestionDTO from MMCA.ADC.Conference.Shared (QuestionLookupService.cs:1), which is the wire contract, not the implementation.
  • Walkthrough (:17-36): GetQuestionsAsync(questionEntity, ct) builds one filtered, paged URL (:25) that constrains QuestionEntity to the URL-escaped argument and QuestionSource to User, with pageSize=100. The Uri.EscapeDataString on the caller-supplied value is the one input-handling detail worth naming: the argument is a string, not an enum, so escaping it is what keeps a stray character from reshaping the query. The retry is the token-aware overload (:27-29), the envelope is read as a PagedCollectionResult<T> (:31-32) and Mapped to a bare IReadOnlyList (:34), so a failure travels on untouched while a success arrives already unwrapped.
  • Why it's built this way: filtering QuestionSource to User server-side rather than fetching everything and filtering in the page keeps system-authored questions off the wire entirely, and pushing QuestionEntity into the query lets the same service serve both feedback pages with no branching.
  • Where it's used: registered scoped as IQuestionLookupService (MMCA.ADC.Engagement.UI/DependencyInjection.cs:41); injected by both feedback pages, EventFeedback (EventFeedback.razor.cs:21) and SessionFeedback (SessionFeedback.razor.cs:20). Covered by QuestionLookupServiceTests.
  • Caveats / not-in-source: questionEntity is an unconstrained string in both the interface (IFeedbackUIService.cs:15) and this implementation, so the valid values ("Event", "Session") are a convention the callers hold, not a type the compiler checks. The pageSize=100 is a hard ceiling in this file.

SessionLookupService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/SessionLookupService.cs:13 · Level 4 · sealed class

  • What it is: the HTTP implementation of ISessionLookupService. It fetches sessions from the Conference-owned sessions resource and builds either a session-keyed lookup of SessionInfo projections or one row.
  • Depends on: ISessionLookupService, SessionInfo, SessionDTO (Conference shared), PagedCollectionResult<T>, HttpResultExecutor, ProblemDetailsResultReader. Externals: IHttpClientFactory (SessionLookupService.cs:13), System.Globalization. Note what is absent: unlike its two neighbours in this folder, it does not derive from AuthenticatedServiceBase and takes no token storage, because it reads the plain "APIClient" (:22, :54) against a public, output-cached listing.
  • Concept introduced, the single-page catalog transfer with a documented cap. GetAllAsync fetches sessions?includeFKs=false&includeChildren=false (:27) with no pagination loop at all, and the inline comment (:24-25) is what makes that safe to read: the base /sessions endpoint has no pageSize parameter, it always serves a single page capped at MaxPageSize (500), which comfortably covers a conference's session catalog. [Rubric §12, Performance & Scalability] assesses transfer sizing; here the includeFKs=false&includeChildren=false query trims the payload on the wire and the five-field SessionInfo projection trims it on the heap, so the whole-catalog read the interface sanctions stays cheap. [Rubric §9, API & Contract Design]: the class consumes Conference through the wire contract only, referencing SessionDTO and nothing else from the other module.
  • Walkthrough
    • GetAllAsync (:17-45) runs inside HttpResultExecutor.ExecuteAsync (:19), reads the response as a PagedCollectionResult<SessionDTO> through ProblemDetailsResultReader.ReadAsync (:30-31), then read.Map(...) (:33) fills a Dictionary<SessionIdentifierType, SessionInfo> by looping page.Items (:36-40) and widens it to IReadOnlyDictionary (:42). Because Map runs on success only, no null guard on the page is needed: a failed read short-circuits carrying the API's own error.
    • GetByIdAsync (:48-67) GETs sessions/{sessionId} with an invariant-culture interpolated URI (:57), reads one SessionDTO (:62) and maps it into a SessionInfo (:64-65). The comment there (:60-61) records the contract from the page's side: a missing session answers 404, which arrives as a NotFound failure rather than the old null, and the pages render exactly the same "session unavailable" state for it.
  • Why it's built this way: building the dictionary once lets a page label many bookmark or attendance rows with O(1) lookups, while the per-id method serves single-session pages without paying the catalog transfer: exactly the fast and slow split ISessionLookupService prescribes. Neither read retries, unlike the token-bearing services in this folder, because both are cheap idempotent GETs against a cached public endpoint.
  • Where it's used: registered scoped as ISessionLookupService (MMCA.ADC.Engagement.UI/DependencyInjection.cs:54). It is also the model the sibling AttendeeLookupService names in its class doc (AttendeeLookupService.cs:12-15) for the search-through, lookup-from-snapshot split. Covered by SessionLookupServiceTests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.UI.Tests/Services/Lookups/SessionLookupServiceTests.cs).
  • Caveats / not-in-source: the 500-row cap is a server-side fact recorded in a comment here, not a value this file reads or asserts. If a conference ever exceeded it, GetAllAsync would silently return a partial catalog, and nothing in this class detects or reports the truncation.

AttendeeSummary

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/IAttendeeLookupService.cs:15 · Level 5 · record

  • What it is: the subset of an attendee's account the check-in surfaces need, five positional members plus one computed display name. Enough to recognise a person standing at a door, and nothing more (IAttendeeLookupService.cs:15-34).
  • Depends on: the UserIdentifierType alias and four strings. Nothing first-party; notably not Identity's UserListDTO.
  • Concept introduced, the consumer-owned view contract. [Rubric §7, Microservices Readiness] assesses whether a module's cross-boundary dependencies are on contracts it can keep working when the other side becomes a separate service. The doc comment (:5-8) states the decision outright: this record is declared here rather than reusing the Identity list DTO, so the Engagement UI depends on the Identity HTTP contract only, not on the Identity assembly. [Rubric §30, Compliance, Privacy & Data Governance] is the second reason to declare it narrowly: the Identity users endpoint can return more about a person than a door scanner needs, and a hand-declared five-field record is a data-minimisation decision expressed in the type system. The same instinct appears one level down in the private AttendeeRow (AttendeeLookupService.cs:184-189), whose comment says only the fields the check-in surfaces render are read.
  • Walkthrough: the positional members are UserId, Email, FirstName, LastName and Role (:16-20). DisplayName (:26-33) is the only behavior: it trims "{FirstName} {LastName}" and falls back to Email when the result is blank, with the comment giving the reason, a row must never render blank. That single fallback is why a caller can bind a grid column straight to DisplayName without a null guard. The sibling enum in the same file, AttendeeSearchField (:41-51), names which of the three fields a single search term applies to, and its own doc explains why that is necessary: the Identity users endpoint ANDs its filters, so broadcasting one term across all three would match nothing (:36-40).
  • Why it's built this way: a record (not a class) gives value equality for free, which matters because the search panel re-fetches pages and compares rows. It stays mutation-free through positional init members, in line with the workspace's required/init default.
  • Where it's used: the grid item type of AttendeeSearchPanel (AttendeeSearchPanel.razor.cs:16, with the filter columns named through nameof(AttendeeSummary.Email) and friends at :80-82, :95-97, :114-117), the payload of its OnCheckIn callback (:27, :130), and the argument of CheckInScan's manual check-in path (CheckInScan.razor.cs:171). Produced only by AttendeeLookupService.ToSummary (AttendeeLookupService.cs:177-178).
  • Caveats / not-in-source: Role is a plain string mirroring whatever the Identity endpoint sends; no enum or constant in this file constrains it.

IAttendeeLookupService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/IAttendeeLookupService.cs:57 · Level 6 · interface

  • What it is: the cross-module lookup contract over the Identity users endpoint, used by the organizer check-in fallback to find an attendee by name or email and to put a name on the person a scan just resolved (:53-56). Two members, deliberately asymmetric.
  • Depends on: AttendeeSummary (the row shape), Result, the UserIdentifierType alias.
  • Concept introduced, a contract shaped by what the server can actually answer. [Rubric §9, API & Contract Design] assesses whether an abstraction reflects the capability underneath it rather than an idealised one. The obvious contract here would be GetByIdAsync, and it is precisely the one the Identity endpoint cannot serve: it filters by name and email but takes no identifier (see the implementation's class doc, AttendeeLookupService.cs:12-15). So the interface exposes what the transport supports, a filtered paged SearchAsync, plus a narrower GetDisplayNameAsync whose implementation is free to solve the identifier problem however it can. [Rubric §1, SOLID]: the two members are the two questions the check-in surfaces ask, and the second returns a string? rather than a full record because a scan only needs a name to render.
  • Walkthrough
    • SearchAsync (:71-79) takes three optional filter fragments (email, firstName, lastName), paging (pageNumber = 1, pageSize = 10), optional sortColumn / sortDirection, and a token. It returns Result<(IReadOnlyList<AttendeeSummary> Items, int TotalItems)>: the tuple exists because a data grid needs the total to draw a pager, not just the page, and the Result exists because a non-organizer caller gets a refusal the panel must render rather than an empty grid. The doc comment (:59-61) names the matching semantics: each filter is a "contains" match, and the server ANDs them. That is the fact the sibling enum AttendeeSearchField exists to work around.
    • GetDisplayNameAsync (:88-90) breaks the pattern on purpose: it returns a bare string?, not a Result. The doc says why (:81-85): null covers both "the account is unknown" and "the roster snapshot behind this lookup could not be read", because naming is a convenience beside a completed check-in and must never fail the caller. A Result here would push a decision onto a page that has nothing useful to do with it.
  • Where it's used: implemented by AttendeeLookupService, registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:55). Injected by AttendeeSearchPanel (AttendeeSearchPanel.razor.cs:21) for the search half and by CheckInScan (CheckInScan.razor.cs:27, used at :223) for the name-resolution half.
  • Caveats / not-in-source: the interface says nothing about caching or freshness; the roster snapshot and its recheck window are entirely an implementation decision, described at AttendeeLookupService.

AttendeeLookupService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Lookups · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Lookups/AttendeeLookupService.cs:17 · Level 7 · sealed class

  • What it is: the HTTP implementation of IAttendeeLookupService over the Identity /Users endpoint. Search reads straight through to the API; the by-identifier lookup is answered from an in-memory roster snapshot this class builds and refreshes itself, because the endpoint cannot be queried by identifier (class doc, :10-16).

  • Depends on: IAttendeeLookupService, AttendeeSummary, AuthenticatedServiceBase, ITokenStorageService, HttpResultExecutor, ProblemDetailsResultReader, PagedCollectionResult<T>. Externals: IHttpClientFactory, TimeProvider, System.Globalization.

  • Concept introduced, the scoped roster snapshot with single-flight and a negative-cache window. This is the most stateful class in the group, and each piece of that state answers a specific failure of the naive version. [Rubric §12, Performance & Scalability] assesses whether repeated work is avoided on a hot path, and the hot path here is real: an organizer scanning badges at a door produces a stream of identifier lookups, many of them for codes the roster does not know. The naive implementation refetched the entire roster on every unrecognised code, which the comment at :113-115 records as the defect this design fixes. Three mechanisms combine:

    1. The snapshot (_rosterNames, :42), an identifier-to-name map built once per scanning session.
    2. Single flight (_rosterLoad, :45, documented at :44 as "published so concurrent misses share it instead of stampeding"), so several misses arriving together await one load.
    3. A negative-cache window (RosterRecheckInterval, 60 seconds, :38), so a miss against a fresh snapshot is reported as a genuine unknown instead of triggering a refetch, while a miss against a stale one refetches and lets an attendee who registered mid-conference resolve on the next scan (:33-37).

    [Rubric §14, Testability] shows in the constructor: TimeProvider? timeProvider = null (:20) defaulting to TimeProvider.System (:40) is what makes the 60-second window testable without a real 60-second wait, and AttendeeLookupServiceTests uses exactly that with a hand-rolled advanceable clock (:50, :107). [Rubric §19, State Management] covers the lifetime question: the service is registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:55), so the snapshot lives and dies with the Blazor circuit rather than being a process-wide cache of attendee names.

  • Walkthrough (constants, then the two public members, then the loader)

    • The constants encode the sizing decisions: RosterPageSize = 500 (:25, matching the server's page cap noted as BR-11) and MaxRosterPages = 5 (:31), whose comment (:27-30) gives both reasons for the bound, five pages cover 2500 accounts (far above the real attendee count) and a paging bug therefore cannot loop forever.
    • SearchAsync (:50-92) assembles its query into a Dictionary<string, string?> with an ordinal comparer (:62-71), then drops blank values and URL-escapes the rest in one LINQ pass (:73-75). That is the notable difference from the hand-built query strings elsewhere in the module: filters here are genuinely optional, so "omit when empty" has to be a rule rather than a set of if statements. The response is read as a PagedCollectionResult<AttendeeRow> (:83-84) and Mapped, on success only, into the page of AttendeeSummary rows plus PaginationMetadata.TotalItemCount (:86-90).
    • GetDisplayNameAsync (:95-132) is the interesting one, and it reads as four ordered guards. A snapshot hit returns immediately (:99-102). Otherwise, if a load is already in flight, the caller awaits that task and reads its result rather than starting a second one (:104-108). Otherwise, if the snapshot exists and is younger than RosterRecheckInterval measured against the injected clock, the miss is answered null (:110-116), with the comment naming the behavior this replaced. Only past that does it start a load, publish it to _rosterLoad (:118-119), await it, and stamp _rosterLoadedAtTicks (:122-123). The finally (:125-129) clears _rosterLoad even on failure, with the comment giving the reason: a failed load must leave the next miss free to retry rather than be told "recently loaded".
    • LoadRosterAsync (:143-175) pages the roster with SearchAsync itself, no filters, 500 rows per page, sorted by Email ascending (:149-154). The sort is not cosmetic: it is what makes page boundaries stable across the several requests that make up one snapshot, so a row cannot be skipped by shifting between calls. A page the API refuses simply breaks the loop (:156-159), and the doc above says why that is acceptable (:138-141): the roster is a naming convenience behind GetDisplayNameAsync, whose contract is already "null when the account cannot be named", so a partial snapshot degrades to that answer instead of failing a check-in. The loop also stops early when a page comes back empty or the accumulated count reaches the server's reported total (:168-171).
    • ToSummary (:177-178) and the private AttendeeRow wire shape (:184-189) close the file, the latter deliberately narrower than what the endpoint returns (:180-183).
  • Why it's built this way: the class doc (:12-15) frames it as mirroring SessionLookupService, search straight through, by-identifier from a snapshot, with a miss refreshing the snapshot once. The design is driven entirely by a server capability gap rather than by a performance target, and the constants are sized for one conference (about 2500 accounts, at a real observed attendance far below that), not for a general-purpose directory.

  • Where it's used: AttendeeSearchPanel drives SearchAsync from both its data-grid loader (AttendeeSearchPanel.razor.cs:79) and its mobile infinite-scroll loader (:94); CheckInScan calls GetDisplayNameAsync after a scan resolves to an identifier (CheckInScan.razor.cs:226). [Rubric §14, Testability]: four facts in AttendeeLookupServiceTests cover exactly the four behaviors above, one roster fetch for repeated unknown ids (:19), a known id answered from the snapshot (:35), a refetch past the recheck interval that resolves a new attendee (:50), and concurrent misses sharing one load (:72).

  • Caveats / not-in-source: three things are worth knowing before relying on this class.

    • The /Users endpoint is permission-gated on IdentityPermissions.UsersRead (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:138-139, BR-51), so a non-organizer caller gets a Forbidden failure out of SearchAsync, not an empty list, and null out of GetDisplayNameAsync.
    • MaxRosterPages * RosterPageSize is a hard 2500-account ceiling on the snapshot. Past it, ids on later pages would never resolve by name, and nothing logs or surfaces that.
    • The single-flight guarantee rests on _rosterLoad being assigned (:119) before any yield point, with no lock taken and the field neither volatile nor interlocked. On the single-threaded Blazor circuit context that holds, and the concurrency test exercises it, but the class is not written to be shared across threads and its scoped registration is what keeps it from being.

LiveEventContext

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveEventContext.cs:13 · Level 0 · sealed record

  • What it is: the UI's read-only view of the currently published event's live window: the event id and name, its IANA time zone, and the UTC start/end of the window during which the live layer (polls and session Q&A) is active. It is a sealed record with two small behaviors attached, not a bare DTO.
  • Depends on: EventIdentifierType (the Conference module's identifier alias), BCL DateTime and TimeZoneInfo. No NuGet or first-party service dependencies; this is a pure value.
  • Concept introduced, client-side re-derivation of a server-enforced window. The doc comment (LiveEventContext.cs:4) is explicit that the window uses "the same math the backend enforces": StartDate 00:00 local through EndDate + 1 day 00:00 local, converted to UTC via the event's zone. The UI does not invent its own liveness rule, it mirrors the authoritative one so the ambient LiveEventListener and the HappeningNow page light up at exactly the moment the API would accept a vote. [Rubric §19, State Management] assesses how derived UI state is kept consistent with its source of truth; the record centralizes the "am I live" decision in one value both surfaces call, rather than scattering time-zone arithmetic across components. [Rubric §12, Performance & Scalability] applies mildly: IsLiveAt is a pure comparison, so an ambient listener can re-evaluate it without a round trip.
  • Walkthrough: the primary constructor (LiveEventContext.cs:13-18) captures EventId, Name, TimeZoneId, LiveWindowStartUtc, and LiveWindowEndUtc. IsLiveAt(DateTime utcNow) (:22-23) returns true when utcNow is inside the half-open window: note the inclusive lower bound and the exclusive < LiveWindowEndUtc upper bound matching the parameter doc that calls the end "exclusive" (:12). ToEventLocal(DateTime utcNow) (:30-31) converts a UTC instant into the event's local time via TimeZoneInfo.ConvertTimeFromUtc over TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId), with no fallback: the doc comment states the id always resolves because EventInvariants.EnsureTimeZoneIsValid guards every write path (:25-28).
  • Why it's built this way: making the record own both the window and the "is it live / what is local time" helpers keeps the liveness contract in a single testable value. The conversion trusts the domain invariant instead of defensively catching TimeZoneNotFoundException, so an invalid zone is prevented where it is written rather than papered over where it is read.
  • Where it's used: produced by LiveEventService from Conference event data; consumed by the HappeningNow page and the ambient LiveEventListener to decide whether to show live surfaces.

CreateBookmarkRequestValidator

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.Create · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkRequestValidator.cs:9 · Level 1 · class (sealed)

  • What it is: the shape gate in front of CreateBookmarkHandler. Two rules, both saying the same thing about a different identifier: a bookmark needs a real user and a real session.
  • Depends on: CreateBookmarkRequest, the UserIdentifierType / SessionIdentifierType aliases; externals: FluentValidation (AbstractValidator<T>, RuleFor, NotEqual, WithMessage, WithErrorCode).
  • Concept introduced, the request validator and where it sits in the command pipeline. [Rubric §9, API & Contract Design] (assesses whether a contract states its own preconditions and reports violations in a machine-readable form) and [Rubric §5, Vertical Slice] (assesses whether everything one use case needs lives in the use case's own folder). The wiring is worth learning once because every validator in this unit rides it. Validators are never resolved by name: ScanModuleApplicationServices<ClassReference>() in the module's composition root (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88) scans the assembly and registers each AbstractValidator<T> as IValidator<T>. At request time ValidatingCommandDecorator<TCommand, TResult> receives IEnumerable<IValidator<TCommand>> and runs ALL of them, not just the first registration (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:19-22,32-37), so a module-authored validator and a framework one both get their say and the caller sees every broken rule in one response. Either way the validation runs before the handler sees the command, and a handler is free to assume the structural rules already held. The other thing to notice is WithErrorCode: the string "UserSessionBookmark.UserId.Required" (CreateBookmarkRequestValidator.cs:16) is a stable code a client can branch on, which a localized message never is.
  • Walkthrough: a block-bodied constructor with two rules (CreateBookmarkRequestValidator.cs:11-22).
    • UserId must not equal default(UserIdentifierType) (CreateBookmarkRequestValidator.cs:13-16). The alias is int (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2), so this rejects an unset zero. Writing it as NotEqual(default(...)) rather than GreaterThan(0) keeps the rule correct if the alias ever changes width or becomes a struct id.
    • SessionId gets the identical treatment against default(SessionIdentifierType) (CreateBookmarkRequestValidator.cs:18-21), also an int alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15).
  • Why it's built this way: the validator deliberately checks only what it can decide without touching a database or another module. Ownership (may this caller bookmark for this user) is enforced in BookmarksController against the caller's user-identifier claim (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:65-71, ADR-033), and session eligibility (BR-49, BR-91) is a cross-module call inside the handler. Three checks, three layers, none duplicated.
  • Where it's used: applied automatically to every CreateBookmarkRequest command entering the pipeline; the request itself arrives from BookmarksController.CreateAsync (BookmarksController.cs:55-78).

ILiveEventUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/ILiveEventUIService.cs:7 · Level 1 · interface

  • What it is: the UI-facing contract for resolving the current published event and its live window. One method, GetCurrentEventAsync, returning a nullable LiveEventContext.
  • Depends on: LiveEventContext and BCL Task/CancellationToken.
  • Concept introduced, nullable-as-absence at a UI boundary. The doc comment (ILiveEventUIService.cs:10-11) says the method returns null "when no published event exists (or the API is unavailable)". Rather than throwing when there is nothing live, the contract makes "no live event" a first-class, expected return that the ambient listener handles by staying dormant. Note the contrast with its sibling contracts: ILivePollUIService and ISessionQuestionUIService answer with Result, which distinguishes "nothing there" from "the call failed"; this interface deliberately collapses both into null because the caller's behavior is identical either way. [Rubric §1, SOLID] applies through Dependency Inversion: components depend on this abstraction, not on the HTTP-bound implementation, so tests can substitute a fake. [Rubric §18, UI Architecture] assesses how the UI layer separates data-resolution contracts from rendering; this interface is that boundary for the live layer.
  • Walkthrough: Task<LiveEventContext?> GetCurrentEventAsync(CancellationToken cancellationToken = default) (ILiveEventUIService.cs:14). The type-level doc names both consumers explicitly, the Happening Now page and the ambient LiveEventListener (:4-5).
  • Why it's built this way: a one-method interface is the minimum surface the HappeningNow page and the ambient listener need, keeping the contract easy to fake and hard to misuse.
  • Where it's used: implemented by LiveEventService; consumed by HappeningNow, LiveEventListener, and CurrentEventNotificationScopeProvider.

RoomCheckInRequestValidator

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordRoomCheckIn · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RoomCheckInRequestValidator.cs:9 · Level 1 · class (sealed)

  • What it is: the one-rule validator for the room-QR self check-in request. The room id must be a positive number, and that is the entire structural contract of the request.
  • Depends on: RoomCheckInRequest, the RoomIdentifierType alias; externals: FluentValidation.
  • Concept reinforced: the pipeline wiring taught under CreateBookmarkRequestValidator. [Rubric §11, Security] is the interesting tag here, in the negative: the request is this thin on purpose. The attendee names a room and nothing else, and the server resolves which session that room is hosting, so there is no field a caller could tamper with to check in somewhere they are not standing (see RecordRoomCheckInHandler).
  • Walkthrough: an expression-bodied constructor holding a single RuleFor (RoomCheckInRequestValidator.cs:11-15): RoomId GreaterThan(0), message "Room ID is required.", error code "CheckIn.RoomId.Required". GreaterThan(0) works because RoomIdentifierType is int (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:12).
  • Where it's used: runs ahead of RecordRoomCheckInHandler, whose command arrives from CheckInsController (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:163,173).

SessionLiveUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/SessionLiveUIService.cs:10 · Level 1 · sealed class

  • What it is: the Engagement-side implementation of the ISessionLiveUIService extension point: it maps a session id to the route of that session's Live page, so the Conference session-detail page can light up a Live button only when the Engagement module is enabled.
  • Depends on: ISessionLiveUIService (the contract, declared in MMCA.ADC.Engagement.Shared.SessionQuestions, imported at SessionLiveUIService.cs:1), EngagementRoutePaths (the route builder), and the SessionIdentifierType alias.
  • Concept introduced, cross-module UI extension point resolved by module presence. Conference must not hard-reference an Engagement route, that would couple the two modules. Instead Conference depends on the abstract ISessionLiveUIService, and Engagement registers this implementation when its module loads. When Engagement is absent, no implementation is registered and the Live button stays off. [Rubric §7, Microservices Readiness] assesses whether modules collaborate through boundaries that survive extraction into separate services; this is the UI-layer version of that discipline, a capability advertised only when its owner is running. [Rubric §1, SOLID] applies through Dependency Inversion: Conference depends on the interface, not on the concrete route builder.
  • Walkthrough: GetSessionLivePath(SessionIdentifierType sessionId) (SessionLiveUIService.cs:13) delegates straight to EngagementRoutePaths.SessionLive(sessionId) (:14). The class is a sealed expression-bodied one-liner carrying /// <inheritdoc />; all it does is put an Engagement route behind a Conference-visible contract.
  • Why it's built this way: routing knowledge for the Live page belongs to Engagement, so Engagement owns the string; Conference only needs the abstraction to conditionally render a link.
  • Where it's used: registered in the Engagement UI module's DI; consumed by the Conference session-detail page to render its Live button, which lands on SessionLive.

SponsorVisitRequestValidator

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordSponsorVisit · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/SponsorVisitRequestValidator.cs:9 · Level 1 · class (sealed)

  • What it is: the sponsor-booth twin of RoomCheckInRequestValidator. One rule, one identifier.
  • Depends on: SponsorVisitRequest, the SponsorIdentifierType alias; externals: FluentValidation.
  • Concept reinforced: same pipeline placement as the two validators above. The shared shape of these two files is not an accident: both flows are attendee-self-recorded scans where the only client input is the id printed on the QR, so both validators have exactly one thing to check.
  • Walkthrough: expression-bodied constructor, one RuleFor (SponsorVisitRequestValidator.cs:11-15): SponsorId GreaterThan(0), message "Sponsor ID is required.", error code "CheckIn.SponsorId.Required". The error code prefix is CheckIn. rather than Sponsor. because a booth visit is stored as a CheckIn row with Scope = Sponsor, and the code namespace follows the aggregate, not the endpoint.
  • Where it's used: runs ahead of RecordSponsorVisitHandler, reached from CheckInsController (CheckInsController.cs:132,142).

LiveBroadcastPatch

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveBroadcastPatch.cs:14 · Level 2 · internal static class

  • What it is: two small helpers that apply a live broadcast payload directly onto a page's in-memory list, patching a poll's tallies or a question's upvote count in place, so the page does not have to refetch from the API to show a fresh count.
  • Depends on: LivePollResultsDTO and SessionQuestionDTO (the two list element types it rewrites) and SessionQuestionUpvoteChangedPayload (the upvote broadcast body). Externals: System.Text.Json with JsonSerializerOptions.Web.
  • Concept introduced, patch-on-broadcast instead of reload-on-broadcast. [Rubric §12, Performance & Scalability] and [Rubric §23, Front-End Performance] assess whether a real-time surface scales with the number of connected clients. The naive pattern is to treat a broadcast as a "something changed" ping and refetch; the comment at HappeningNow.razor.cs:135-137 records what that costs in practice, V votes times C viewers becoming V*C authenticated refetches under burst voting. The broadcasts here already carry the fresh counts (BR-229/BR-238, LiveBroadcastPatch.cs:9-10), so the payload is the update, not a notification of one. [Rubric §19, State Management] is the discipline that makes it safe: these two helpers are the only events treated this way. Every structural event (a poll opened or closed, a question posted or answered) still reloads, so the optimization is confined to counters where a lost update self-corrects on the next broadcast.
  • Concept, the boolean fallback contract. Both methods return false for anything they cannot apply (:26, :53), and the caller reloads. That is what keeps the fast path from becoming a correctness risk: an unknown poll id, an unknown question id or an unparsable body all degrade to the behavior the page would have had without the patch at all.
  • Walkthrough:
    • TryApplyPollResults(polls, payloadJson, preserveMyVote) (:27-48) deserializes the payload as a LivePollResultsDTO (:31), finds the matching entry by PollId (:35), and replaces it in place (:39-41). The preserveMyVote flag is the subtle parameter and its doc (:19-25) is the reason it exists: the broadcast strips per-user data, so an attendee circuit passes true and carries the current entry's MyVoteOptionId across the patch, or the marker rendering the viewer's own vote would vanish; the projector view passes false because it shows no personal vote and a stale marker must not survive into its list.
    • TryApplyUpvoteCount(questions, payloadJson) (:54-73) is the simpler twin: deserialize SessionQuestionUpvoteChangedPayload (:58), find by Id (:62), and rewrite only the UpvoteCount with a with expression (:66), leaving every other field of the question untouched.
    • Both wrap the work in try/catch (JsonException) (:44-47, :69-72), catching the parse failure specifically rather than swallowing everything.
  • Why it's built this way: internal static with no injected state is the honest shape. There is nothing to configure and nothing to mock: the input is a list and a string, the output is a bool, and the three pages that use it call it identically. Using with expressions on the record DTOs rather than mutating them keeps the list elements immutable values, which is what lets a page compare or re-render without worrying that a background patch aliased an object it already handed to a component.
  • Where it's used: by the three conference-day pages, each guarding the call with the channel's event name so an unrelated broadcast never reaches it. SessionLive routes both events through one switch and reloads on null (SessionLive.razor.cs:188-195, patching at :190 and :192 with preserveMyVote: true); PresenterView handles them as two early-return branches with preserveMyVote: false (PresenterView.razor.cs:121, :127); HappeningNow patches only poll results and falls back to ReloadPollsAsync (HappeningNow.razor.cs:140, :145).
  • Caveats / not-in-source: the class is internal, so nothing outside MMCA.ADC.Engagement.UI can call it, and no dedicated test class for it exists in the repository. Its behavior is exercised only through the pages that call it.

ILivePollUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/ILivePollUIService.cs:15 · Level 3 · interface

  • What it is: the full UI contract for the live-poll layer: read open polls (event-wide or per session) with tallies and the caller's own vote, cast or change a vote, and drive the organizer lifecycle (create, open, close, delete), plus a session-scoped manage list.
  • Depends on: LivePollResultsDTO, LivePollDTO, CreateLivePollRequest (all MMCA.ADC.Engagement.Shared.LivePolls, imported at ILivePollUIService.cs:1), Result (MMCA.Common.Shared.Abstractions, :2), and the LivePollIdentifierType/LivePollOptionIdentifierType/EventIdentifierType/SessionIdentifierType aliases.
  • Concept introduced, a Result-typed UI contract: a server refusal is data, not an exception. The type doc states it outright (ILivePollUIService.cs:11-12): "Every member answers with a Result: a server refusal is data the page renders, never an exception. Only the caller's own cancellation still propagates." Every one of the ten members returns Task<Result> or Task<Result<T>>, so a 403 from a poll the caller may not manage, or a 412 from a stale open, arrives as a value a Blazor page can bind to an alert. [Rubric §24, Forms/Validation/UX Safety] assesses whether failure states are renderable rather than fatal; typing the contract on Result removes the try/catch from every consuming page. A second idea also appears here, two result shapes for two audiences: read-and-vote methods return LivePollResultsDTO (tallies plus the caller's own vote) while the manage views return the richer LivePollDTO. [Rubric §9, API & Contract Design] covers that fit-the-shape-to-the-consumer split. [Rubric §11, Security] applies through the explicit note that manage operations require engagement:live:manage and that "the API enforces this regardless of what the UI renders" (:8-9): the UI contract never pretends to be the security boundary.
  • Walkthrough: attendee and reader path: GetOpenPollsAsync (ILivePollUIService.cs:18), GetOpenSessionPollsAsync (:21), GetResultsAsync (:24), CastVoteAsync (:27, which returns the fresh tallies so a vote needs no follow-up read). Manage path: GetEventPollsAsync (:30, ALL polls of an event) and GetSessionManagePollsAsync (:36), whose doc records that the session list is "open to the session's assigned speakers as well as organizers (BR-236), unlike the event-wide manage list" (:33-34). Lifecycle: CreateAsync (:39, creates as Draft), OpenAsync (:42) and CloseAsync (:45), each taking a byte[] rowVersion alongside the id, and DeleteAsync (:48, "must not be Open"). The rowVersion parameter on the two transitions is the optimistic-concurrency token of ADR-035: the caller must state which version of the poll it saw. Every method takes a trailing CancellationToken.
  • Why it's built this way: grouping the whole poll lifecycle behind one interface lets the various poll surfaces (HappeningNow, SessionLive, SessionLiveModerationPanel, PresenterView) inject a single dependency and lets tests fake it wholesale, while the Result return type keeps every one of those pages free of HTTP error handling.
  • Where it's used: implemented by LivePollUIService; consumed by the live-poll Blazor pages and panels.

ISessionQuestionUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/ISessionQuestionUIService.cs:15 · Level 3 · interface

  • What it is: the UI contract for the session Q&A layer: attendees read approved questions and submit their own, moderators work a queue (approve / dismiss / mark-answered), and anyone can upvote or remove an upvote.
  • Depends on: SessionQuestionDTO and SubmitQuestionRequest (MMCA.ADC.Engagement.Shared.SessionQuestions, imported at ISessionQuestionUIService.cs:1), Result, and the SessionIdentifierType/SessionQuestionIdentifierType aliases.
  • Concept introduced, two views of the same queue. GetQuestionsAsync (ISessionQuestionUIService.cs:18) returns the attendee view (every approved question plus the caller's own pending/dismissed ones), while GetModerationQueueAsync (:21) returns all statuses with Pending first. The same two disciplines as its poll sibling appear verbatim: every member answers with a Result and only caller cancellation propagates (:11-12), and moderation needs organizer/admin or an assigned-speaker claim with "the API enforces this regardless of what the UI renders (BR-236)" (:8-9). [Rubric §24, Forms/Validation/UX Safety] assesses how submission and moderation flows are shaped; the split read methods keep an attendee from seeing another attendee's un-approved question while giving moderators the full picture. [Rubric §11, Security] applies through the server-authoritative moderation note.
  • Walkthrough: reads: GetQuestionsAsync (:18), GetModerationQueueAsync (:21). Submit: SubmitAsync (:24), whose doc notes the question "starts at the event's moderation default (BR-233)". Moderation: ApproveAsync (:27), DismissAsync (:30), MarkAnsweredAsync (:33), each taking (SessionQuestionIdentifierType id, byte[] rowVersion, CancellationToken) so the write is conditional on the version the moderator saw (ADR-035) and two moderators racing cannot silently overwrite each other. Upvoting: UpvoteAsync (:36) and RemoveUpvoteAsync (:39), each returning Result<int>, the fresh upvote count, so the button can re-render from the response.
  • Why it's built this way: one interface spans attendee, moderator, and voter roles so a session Live page injects a single service; the API remains the enforcer, so the contract can expose the moderation methods without granting rights. Returning the count from the upvote verbs saves a follow-up read on the hottest interaction in the room.
  • Where it's used: implemented by SessionQuestionUIService; consumed by SessionLive, SessionLiveModerationPanel, SessionLiveQuestionPanel, and PresenterView.

LiveChannelSubscription

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveChannelSubscription.cs:15 · Level 3 · sealed class

  • What it is: a join-once / leave-once handle for one live notification channel, held as a field by the conference-day pages that listen for real-time events. It owns the two pieces of state and the join and leave sequences those pages used to repeat inline, so a page states only which channel it wants and what to do with an event (class doc, :5-9).
  • Depends on: NotificationHubService (:1, passed in per join rather than injected). Externals: IAsyncDisposable, IDisposable, GC.SuppressFinalize.
  • Concept introduced, the component-owned resource handle. [Rubric §19, State Management] assesses where transient client state lives and who is responsible for tearing it down. The remarks block (:11-14) is the whole design in three sentences: this is not a DI service. A page news one up as a field, so the handle's lifetime is the component's, nothing has to be registered or scoped, and disposal is a no-op for a handle that never joined. Compare that with the alternative of a scoped service tracking channel membership: it would outlive the component and would have to be told when the component went away. Here the component's own DisposeAsync is the only signal needed. [Rubric §15, Best Practices & Code Quality]: two pages held the identical field pair and repeated the same three-step join and two-step leave; folding that into one type means a change to the join protocol happens once.
  • Walkthrough
    • The three fields (:17-19) are the hub service, the handler subscription token, and the channel key. All three are nullable and all three are set together.
    • IsJoined (:25) reports simply whether _channelKey is non-null. Its doc (:21-24) names the exact job it does: the pages call it as the already-joined guard inside OnAfterRenderAsync, which is deliberately not firstRender-gated (BR-229 / BR-238). See HappeningNow at HappeningNow.razor.cs:114 and SessionLive at SessionLive.razor.cs:138 for the guard in place.
    • JoinAsync (:35-51) null-checks the hub service (:40), returns early when a key is already recorded so a second call is a no-op (:42-45), then assigns all three fields and subscribes the handler through OnChannelEvent (:47-49) before awaiting JoinChannelAsync (:50). The ordering is the load-bearing part and the doc says why (:28-30): the key is recorded before the join is awaited, so IsJoined already answers true for any render that interleaves with the in-flight join. Without that, a render landing mid-join would see false and start a second join.
    • DisposeAsync (:54-63) disposes the handler subscription with the null-conditional (:56), leaves the channel only when both the service and the key are present (:57-60), and calls GC.SuppressFinalize (:62). A handle that never joined therefore disposes cleanly with no hub traffic.
  • Why it's built this way: real-time listening on a Blazor page is a resource with a strict pairing (subscribe and join on the way in, unsubscribe and leave on the way out) that the render loop can interleave with. Making the handle a plain field with an idempotent join and an idempotent dispose puts both halves in one place a reader can check at a glance.
  • Where it's used: HappeningNow holds one for the event channel (HappeningNow.razor.cs:52, joined at :124-127 via LivePollChannel.ForEvent, disposed at :276) and SessionLive holds one for the session channel (SessionLive.razor.cs:57, joined at :143 via LivePollChannel.ForSession, disposed at :329). Both guard the join with RendererInfo.IsInteractive as well, which keeps the prerender pass and the bUnit suite off the hub (HappeningNow.razor.cs:110-114).
  • Caveats / not-in-source: the class is not thread-safe. JoinAsync reads and writes _channelKey around an await with no lock, which holds on the single-threaded Blazor circuit context the pages run on and is not written to be shared beyond it. There is also no LeaveAsync: leaving is disposal, so a page that wanted to switch channels would need a new handle.

LivePollUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LivePollUIService.cs:15 · Level 4 · sealed class

  • What it is: the HTTP implementation of ILivePollUIService, calling the Gateway's /LivePolls routes with an authenticated client, a retry pipeline, and Problem-Details-to-Result translation.
  • Depends on: AuthenticatedServiceBase (base class supplying CreateAuthenticatedClientAsync, RetryPolicy, and NewIdempotencyKey), HttpResultExecutor and ProblemDetailsResultReader, ConcurrencyETag and IdempotencyHeaders (MMCA.Common.Shared.Http), ILivePollUIService and the poll DTO/request records, plus IHttpClientFactory and ITokenStorageService (LivePollUIService.cs:15-17).
  • Concept introduced, the two-halves translation of an HTTP call into a Result. Every method here has the same skeleton: the body is wrapped in HttpResultExecutor.ExecuteAsync(..., cancellationToken) and ends in a ProblemDetailsResultReader.ReadAsync call. Those two do complementary jobs: the reader converts a response (an RFC 9457 Problem Details body from the API back into the original Error list, preserving ErrorType), and the executor converts the absence of a response (a refused connection, DNS failure, dropped socket, or HttpClient timeout) into a transport failure, while rethrowing an OperationCanceledException that the caller's own token caused. Together they are what makes the Result-typed contract in ILivePollUIService honest. Between them sits RetryPolicy.ExecuteAsync from the base. [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether resilience and error translation are applied uniformly; every method here gets all three for free by construction. [Rubric §26, Front-End Security] applies because each call acquires a bearer-token-bearing client via CreateAuthenticatedClientAsync.
  • Walkthrough: Endpoint = "livepolls" (LivePollUIService.cs:19). Reads: GetOpenPollsAsync (:22) GETs livepolls/open?eventId= built with string.Create(CultureInfo.InvariantCulture, ...) (:30), and read.Map(...) widens List<LivePollResultsDTO> to IReadOnlyList<...> (:36); GetOpenSessionPollsAsync (:41) is the same call keyed by sessionId (:49); GetResultsAsync (:60) GETs livepolls/{pollId}/results (:68); GetEventPollsAsync (:106) GETs livepolls?eventId= (:114) and GetSessionManagePollsAsync (:125) GETs livepolls/manage?sessionId= (:133), both returning LivePollDTO lists. Non-idempotent writes carry an idempotency key: CastVoteAsync (:78) adds IdempotencyHeaders.IdempotencyKey with NewIdempotencyKey() (:93) then POSTs a CastVoteRequest (:95) to livepolls/{pollId}/votes (:98), and CreateAsync (:144) does the same before POSTing the CreateLivePollRequest (:156, :159-160). The long comments at :87-92 and :152-155 explain the placement precisely: the key is minted ONCE outside the retry pipeline, because generating it inside the retried delegate would give every attempt a fresh key and leave the server nothing to deduplicate on, turning a retried vote into a second write. Lifecycle: OpenAsync (:168) and CloseAsync (:172) both delegate to PostLifecycleAsync(pollId, action, rowVersion, ...) (:190), which formats the row version with ConcurrencyETag.Format (:197) and, per attempt, builds a fresh HttpRequestMessage carrying ConcurrencyETag.IfMatchHeaderName (:205-206) because a sent request message cannot be re-sent by the retry pipeline (:199-201); a stale-view open or close answers 412 Precondition Failed. DeleteAsync (:176) DELETEs livepolls/{pollId} (:183) with no precondition.
  • Why it's built this way: folding the repeated client / retry / read ceremony into shared helpers keeps each method down to its URL and payload, so the class reads as a faithful map of the interface onto REST routes. The idempotency-key and If-Match placements are the two spots where that ceremony genuinely matters, and both carry an in-code explanation so a later edit does not quietly move them inside the retry delegate (ADR-035, ADR-021).
  • Where it's used: registered as the ILivePollUIService in the Engagement UI module; injected into HappeningNow, SessionLivePollPanel, SessionLiveModerationPanel, and PresenterView.

SessionQuestionUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/SessionQuestionUIService.cs:15 · Level 4 · sealed class

  • What it is: the HTTP implementation of ISessionQuestionUIService, calling the Gateway's /SessionQuestions routes with the same executor + retry + reader shape as LivePollUIService.
  • Depends on: AuthenticatedServiceBase, HttpResultExecutor, ProblemDetailsResultReader, ConcurrencyETag, IdempotencyHeaders, ISessionQuestionUIService, SessionQuestionDTO/SubmitQuestionRequest, IHttpClientFactory, and ITokenStorageService (SessionQuestionUIService.cs:15-17).
  • Concept reinforced: this is the structural sibling of LivePollUIService and reuses the same triad (executor wrapper, RetryPolicy.ExecuteAsync, ProblemDetailsResultReader.ReadAsync) introduced there, so the concept is not re-taught. What differs is the count-returning upvote pair and the moderation helper. [Rubric §29, Resilience, Reliability & Business Continuity] and [Rubric §26, Front-End Security] apply for the same inherited-resilience and token-flow reasons as its poll sibling.
  • Walkthrough: Endpoint = "sessionquestions" (SessionQuestionUIService.cs:19). Reads: GetQuestionsAsync (:22) GETs sessionquestions?sessionId= (:30) and GetModerationQueueAsync (:41) GETs sessionquestions/moderation?sessionId= (:49), each widening the deserialized List<SessionQuestionDTO> via read.Map (:36, :55). SubmitAsync (:60) mints an idempotency key outside the retry pipeline (:73) before POSTing the SubmitQuestionRequest (:76-77); the comment (:68-72) names the concrete failure it prevents, an attendee whose submit times out over conference wifi posting the same question twice. Moderation: ApproveAsync (:85), DismissAsync (:89), and MarkAnsweredAsync (:93) each delegate to PostModerationAsync(id, action, rowVersion, ...) (:126) with the action strings "approve", "dismiss", and "answered"; that helper formats ConcurrencyETag.Format(rowVersion) (:133) and builds a fresh HttpRequestMessage per attempt carrying If-Match (:141-142), so "two moderators racing surface as 412 Precondition Failed" (:136). Upvotes: UpvoteAsync (:97) POSTs sessionquestions/{id}/upvotes with a null body (:104) and RemoveUpvoteAsync (:112) DELETEs the same route (:119); both deserialize the fresh count with ProblemDetailsResultReader.ReadAsync<int> (:107, :122) and neither carries a key or a precondition, because setting or clearing one caller's upvote is naturally idempotent.
  • Why it's built this way: one private helper (PostModerationAsync) collapses the repeated conditional-POST ceremony for the three moderation verbs, leaving each public method to state only its action string. The deliberate asymmetry (keys on submit, If-Match on moderation, neither on upvote) matches each operation's actual retry hazard rather than applying one blanket policy.
  • Where it's used: registered as ISessionQuestionUIService; consumed by SessionLive, SessionLiveModerationPanel, SessionLiveQuestionPanel, and PresenterView.

CreateBookmarkHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.UseCases.Create · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:16 · Level 9 · class (sealed partial)

  • What it is: the create-or-reactivate use case for a session bookmark. It enforces BR-21 (no duplicate), BR-49 and BR-91 (the session must be bookmarkable), and BR-135 (re-bookmarking revives the soft-deleted row instead of inserting a new one).
  • Depends on: IUnitOfWork, ISessionBookmarkValidationService (Conference-owned), IBookmarkManagementDomainService, UserSessionBookmarkDTOMapper, IUniqueConstraintViolationDetector, UserSessionBookmark, Result / Error, ICommandHandler<in TCommand, TResult>; externals: ILogger<T> with [LoggerMessage] source generation.
  • Concept introduced, the reactivating create and the pre-check-plus-index pair. [Rubric §8, Data Architecture] (assesses whether soft-delete semantics are honored on the write path, not just filtered on reads), [Rubric §6, CQRS & Event-Driven], and [Rubric §7, Microservices Readiness]. Two ideas live here. First, under a soft-delete-everywhere policy (ADR-005) a "create" is not always an insert: the row may already exist with IsDeleted = true, and inserting a second one would both violate the unique index and orphan the audit trail. Second, a uniqueness pre-check in application code is never the guarantee, only the pleasant path: two concurrent requests can both pass it, so the database index has to be the real rule and the handler has to translate its violation back into the same domain error. Both patterns recur in PointsAwarder, which is the best evidence they are the module's convention rather than one-off defensiveness.
  • Walkthrough: six constructor dependencies (CreateBookmarkHandler.cs:16-22), then one HandleAsync (:26-89).
    • Cross-module eligibility first (:30-33): ValidateSessionForBookmarkAsync is a Conference contract, satisfied in-process in the monolith and by a gRPC client when the modules run as separate services (ADR-007). This is the reciprocal half of the bidirectional pair: Conference calls Engagement's BookmarkCountService in the other direction, which is exactly why the AppHost deliberately omits a reciprocal WaitFor.
    • BR-21 duplicate check (:37-48) via ExistsAsync, returning Error.Conflict with code "UserSessionBookmark.Duplicate".
    • BR-135 revival lookup (:50-55): one call to FindIncludingDeletedAsync with asTracking: true, which returns the matching rows already partitioned into (Active, SoftDeleted) and lets the handler discard the active half with _. The framework calls this "the resurrection read" and documents both halves of the contract (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:196-215): dropping the query filter means "include soft-deleted rows" and nothing more (the tenant filter stays in force), and asTracking: true is required because the row is about to be mutated rather than inserted, since an untracked source would leave the reactivation silently unsaved.
    • The decision itself is delegated to the domain service (:57-60): CreateOrReactivate(deletedBookmark, ...) returns either a fresh aggregate or the revived one, and the handler only decides persistence: AddAsync runs solely when there was no deleted row to revive (:62-66).
    • The save is wrapped in a filtered catch (:68-84). uniqueConstraintViolationDetector.IsUniqueConstraintViolation(exception) recognizes the unique index on (UserId, SessionId) firing, logs at Debug, and returns the SAME conflict error the pre-check produces (:79-83), so a lost insert race reads to the caller as an ordinary 409 instead of a 500. Note the detector is INJECTED rather than a static helper, which is what keeps the provider-specific error classification swappable per database engine.
    • Success logs at Information and returns the mapped DTO (:86-88). Both log lines are [LoggerMessage] partials (:91-95), so the message templates are compiled rather than formatted per call.
  • Why it's built this way: reactivation preserves the audit fields and any scalar references pointing at the bookmark, which is the whole point of soft delete; and pushing the create-or-reactivate choice into a domain service keeps the invariant testable without a database.
  • Where it's used: resolved as ICommandHandler<CreateBookmarkRequest, Result<UserSessionBookmarkDTO>> by BookmarksController (BookmarksController.cs:35,73), which performs the ownership check first and returns 201 with a /bookmarks/{id} location on success (BookmarksController.cs:75-77). Registered by the convention scan (DependencyInjection.cs:88) and therefore wrapped by the decorator pipeline, so validation, logging, and the transaction are already applied around it.

LiveEventService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.SessionLive · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveEventService.cs:14 · Level 9 · sealed class

  • What it is: the implementation of ILiveEventUIService: it fetches the currently-live-or-next published event from the Conference API and computes its live window into a LiveEventContext, degrading to null when nothing is live or the API is down.
  • Depends on: ILiveEventUIService, CurrentEventSelector (the shared selection plus window math), EventDTO and PagedCollectionResult<T>, LiveEventContext, and IHttpClientFactory (LiveEventService.cs:14). It uses the plain "APIClient" (:21), not the authenticated base: the published-events read is public.
  • Concept introduced, fail-soft resolution: absence and failure both become null. This is the one live-layer service that has not moved to the Result shape, and the choice is deliberate rather than an omission: the type doc states that "API failures degrade to null so the live layer simply stays dormant" (LiveEventService.cs:11-12), and the only consumer behavior for both cases is "render nothing". [Rubric §29, Resilience & Business Continuity] assesses graceful degradation under a dependency outage; here an unavailable Conference API turns the live layer off instead of erroring a page. [Rubric §12, Performance & Scalability] applies because the same public, output-cached events read backs this call. The trade-off worth naming: a page cannot tell an outage from "the conference has not started", so no diagnostic message is possible at this boundary.
  • Walkthrough: GetCurrentEventAsync (:17) opens a try (:19), creates the "APIClient" (:21), and fetches events?includeFKs=false&includeChildren=false as PagedCollectionResult<EventDTO> via GetFromJsonAsync (:23-25). It hands CurrentEventSelector.SelectCurrentOrNext the published subset with a null-safe wrapper?.Items?.Where(e => e.IsPublished) ?? [] (:28), accessor lambdas for start date, end date, and time zone (:29-31), and DateTime.UtcNow (:32); a null selection returns null (:33-36). On a hit it computes (startUtc, endUtc) from CurrentEventSelector.GetLiveWindowUtc(StartDate, EndDate, TimeZone) (:38-39) and constructs the LiveEventContext from the event id, name, time zone, and window (:41-46). A thrown HttpRequestException is caught (:48) and also returns null, with a comment recording that the live layer stays dormant while the API is unavailable (:50-51).
  • Why it's built this way: delegating both "which event" and "what window" to CurrentEventSelector means the UI and the API share one implementation of the conference-day math, which is what lets LiveEventContext claim to mirror the backend window exactly. The two null paths keep the ambient listener silent whenever there is nothing to show.
  • Caveats / not-in-source: the catch covers HttpRequestException only (:48). A malformed JSON body would surface a JsonException instead, and how that propagates is not handled here; nothing in this file states the intended behavior for it.
  • Where it's used: registered as ILiveEventUIService; consumed by HappeningNow, the ambient LiveEventListener, CheckInScan, and OrganizerAttendance.

RecordRoomCheckInHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordRoomCheckIn · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:28 · Level 12 · class (sealed partial)

  • What it is: the attendee-facing self check-in from a room's printed QR. The request names a room; the SERVER decides which session that room is hosting right now and records a session check-in for the caller.
  • Depends on: IUnitOfWork, IEventLiveValidationService and its RoomSessionInfo result, ICurrentUserService, CheckInSettings via IOptions<T>, CheckInProcessor, CheckIn, RoomCheckInResultDTO, ICommandHandler<in TCommand, TResult>; externals: TimeProvider, ILogger<T>.
  • Concept introduced, server-side resolution as the anti-abuse property. [Rubric §11, Security] (assesses whether authority for a decision sits with the party that can be trusted with it) and [Rubric §12, Performance & Scalability] in passing. The class doc states the property (RecordRoomCheckInHandler.cs:16-27): because only the room travels in the request, a shared room link records nothing outside the session's window, and nobody can name a session they are not standing in. Worth contrasting with the organizer paths, where the client does send a session id: there the caller is a trusted organizer, here the caller is the beneficiary of the write. The second teachable point is the answer-shape rule at :53-63: an unknown room and a room with nothing scheduled deliberately produce the SAME attendee-facing error, because distinguishing them would leak which room ids exist. Transport-level failures still propagate unchanged, so a Conference outage is not misreported as an empty schedule.
  • Walkthrough: six constructor dependencies (:26-32), one HandleAsync (:35-96).
    • Caller identity from the token, never the request (:39-45): RequireUserId("CheckIns.Forbidden"), the same guard the organizer handlers use.
    • Cross-module resolution (:50-52): GetCurrentRoomSessionInfoAsync(command.RoomId, settings.Value.RoomCheckInGraceMinutes, ...). The grace window travels in the call rather than living in Conference because it is this module's policy (:47-49); its default is 15 minutes (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInSettings.cs:21), and the setting's own doc (CheckInSettings.cs:16-20) explains that a zero-grace window would reject the entire pre-session queue.
    • Failure triage (:60-62): a NotFound in the returned errors collapses to the shared NoCurrentSession() error (:98-102); anything else is passed through verbatim.
    • Published gate (:65-67): an unpublished event fails even when a session resolves, and the failure is the processor's shared EventNotPublished(...) with this handler named as the source (CheckInProcessor.cs:29-33), so the error still points at the entry point the caller used.
    • The write goes through CheckInProcessor.RecordAsync (:71-81) with CheckInScope.Session, the event id Conference reported, sponsorId: null, and checkedInByUserId: userId. That last argument is the only field that distinguishes a self check-in from a door scan (:22-23), which is what keeps the two operationally separable while everything downstream (points award, attendance rollup, export posture) treats them identically. Idempotency lives inside RecordAsync: it looks for an existing row on the same scope-specific predicate the organizer paths use (CheckInProcessor.cs:201-220) and, when it finds one, returns AlreadyRecorded: true with the ORIGINAL CheckedInOn rather than writing again (CheckInProcessor.cs:71-75).
    • The response (:89-95) carries the session title Conference returned plus AlreadyCheckedIn and CheckedInOn from the outcome; the Information log fires only on a genuinely new row (:86-87). timeProvider.GetUtcNow() supplies the timestamp inside the processor (CheckInProcessor.cs:83), so the flow is testable without wall-clock dependence ([Rubric §14, Testability]).
  • Why it's built this way: writing an ordinary session check-in rather than a new row type means AttendeeCheckedInPointsHandler, the rollups, and the privacy export need no change at all to cover the self-service surface.
  • Where it's used: CheckInsController resolves it as ICommandHandler<RoomCheckInRequest, Result<RoomCheckInResultDTO>> (CheckInsController.cs:45,173).

RecordSponsorVisitHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordSponsorVisit · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:36 · Level 12 · class (sealed partial)

  • What it is: records the caller's visit to one sponsor booth, scanned from the sponsor's printed deep-link QR. The row is a CheckIn with Scope = Sponsor whose CheckedInByUserId equals its UserId, which is what marks it self-recorded.
  • Depends on: IUnitOfWork, IEventLiveValidationService and its SponsorLiveInfo result, ICurrentUserService, CheckInProcessor, SponsorVisitResultDTO, ICommandHandler<in TCommand, TResult>; externals: TimeProvider, ILogger<T>.
  • Concept introduced, where to draw the reuse line, and a written-down accepted trade-off. [Rubric §15, Best Practices & Code Quality] and [Rubric §11, Security]. The doc comment (RecordSponsorVisitHandler.cs:19-34) is the most instructive part of the file and rewards reading in full. It argues first that this flow is deliberately not a third branch inside CheckInProcessor.ExecuteAsync: that entry point resolves its own target through Conference, and this flow asks a different question (the sponsor lookup) and answers in a different shape (the sponsor name), so folding it in would make the organizer path carry conditionals for a question it never asks. The reuse line is drawn one level lower instead: the caller check, the not-published failure, and the idempotent record all still run through CheckInProcessor, just through RecordAsync and EventNotPublished rather than ExecuteAsync. Splitting a helper by which half is genuinely shared, rather than sharing the whole entry point, is the transferable lesson. Then the doc states the trade-off honestly (:25-32): the deep-link URL is shareable, exactly like the badge QR, and the live window is deliberately not enforced. What makes sharing worthless is the combination of gates: the owning event must be published, the filtered unique index caps the award at one per sponsor per attendee, the configured value is low with 0 turning it off entirely (PointsSettings), and the feature flag answers 404 when the surface is retired. Rotating tokens and date windows were considered and rejected as inconsistent with the badge QR. Documenting a rejected alternative next to the accepted risk is what makes this an architecture decision rather than an oversight.
  • Walkthrough: five constructor dependencies (:34-39), one HandleAsync (:42-94).
    • ArgumentNullException.ThrowIfNull(command) (:46) and the caller from the token (:48-52), same CheckIns.Forbidden guard as the room flow.
    • Sponsor lookup (:56-60): GetSponsorLiveInfoAsync proves the sponsor exists and returns its owning event plus display name, so a QR printed for a sponsor that was later pulled records nothing (:54-55). Unlike the room flow, failures pass through unchanged, since a nonexistent sponsor id is not the same kind of enumeration risk as a room schedule.
    • Published gate (:63-64), the same shared EventNotPublished error as the other check-in paths, sourced to this handler.
    • The write through CheckInProcessor.RecordAsync (:70-79) with CheckInScope.Sponsor, sessionId: null, the sponsor id, and checkedInByUserId: userId. Idempotency again lives in the processor: a (UserId, SponsorId, Scope == Sponsor) predicate (CheckInProcessor.cs:222-223) means a second scan of the same booth reports the ORIGINAL visit instead of writing a second row. The comment names the filtered unique index on (UserId, SponsorId) WHERE [Scope] = 2 as the race backstop (:66-68).
    • The response carries the sponsor name (:87-93) so the attendee's confirmation screen needs no second cross-module call, and the Information log fires only on a genuinely new visit (:84-85).
  • Why it's built this way: returning the same DTO shape on both the fresh and repeat paths means the client renders one screen, and the AlreadyVisited flag is presentation, not an error the user has to interpret.
  • Where it's used: CheckInsController (CheckInsController.cs:44,142).

CheckInAttendeeRequestValidator

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.CheckInAttendee · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeRequestValidator.cs:9 · Level 2 · class (sealed)

  • What it is: the validator for the organizer scanning path. Four rules covering the badge credential, the scope, the event, and the conditionally required session.
  • Depends on: CheckInAttendeeRequest, CheckInScope, the EventIdentifierType alias; externals: FluentValidation (NotEmpty, IsInEnum, NotEqual, NotNull, When).
  • Concept introduced, the conditional rule. [Rubric §9, API & Contract Design] (assesses whether a request that means different things in different modes states which fields each mode requires). The request is one payload serving two scopes, so a flat "all fields required" rule set would be wrong for an event-scope check-in and a flat "nothing required" set would let a session check-in arrive with no session. FluentValidation's .When(...) states the mode-specific half declaratively (CheckInAttendeeRequestValidator.cs:33) instead of pushing an if into the handler. IsInEnum() is the second thing worth naming: a C# enum backed by int will happily deserialize an out-of-range number, so an enum field is not self-validating and the rule at :18-21 is the check that makes it so.
  • Walkthrough: four rules in the constructor (CheckInAttendeeRequestValidator.cs:11-34).
    • Credential NotEmpty() with message "Badge credential is required." and code "CheckIn.Credential.Required" (:13-16). Presence only: whether the credential decodes and whether it names a real badge are decided in the handler, and both failures are answered identically there for anti-oracle reasons.
    • Scope IsInEnum() with code "CheckIn.Scope.Invalid" (:18-21).
    • EventId not default(EventIdentifierType) with code "CheckIn.EventId.Required" (:23-26).
    • SessionId NotNull() with code "CheckIn.SessionId.Required", guarded by .When(x => x.Scope == CheckInScope.Session) (:29-33). The comment above it (:28) states the asymmetry outright: the event is required for both scopes, only the session is scope-conditional.
  • Why it's built this way: the event stays mandatory even for a session scan although the shared core will later overwrite it with the event Conference reports for that session (CheckInProcessor, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:124-128,174-177). Requiring it keeps the request self-describing for logs and clients; letting the server value win keeps a stale organizer screen from filing attendance under the wrong event.
  • Where it's used: runs ahead of CheckInAttendeeHandler (CheckInsController.cs:77,88).

LeaderboardOptInChanged

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Points.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/DomainEvents/LeaderboardOptInChanged.cs:15 · Level 2 · record (sealed)

  • What it is: the in-process domain event the LeaderboardOptIn aggregate raises when an attendee joins the public leaderboard, rejoins it, or leaves it.
  • Depends on: BaseDomainEvent, DomainEntityState, the LeaderboardOptInIdentifierType / UserIdentifierType aliases.
  • Concept introduced, the one-event-per-aggregate shape (BR-60). [Rubric §4, DDD] (assesses whether state changes are named domain facts rather than inferred from rows) and [Rubric §6, CQRS & Event-Driven]. Instead of LeaderboardOptInCreated plus LeaderboardOptInDeleted, the module raises ONE record carrying a DomainEntityState discriminator (LeaderboardOptInChanged.cs:16). The doc comment states the convention (:8-9) and the module holds to it for all three of its aggregates in this unit. The payload discipline is the second lesson: the comment says outright that the event carries no display name because "the event is a signal, not a publication channel" (LeaderboardOptInChanged.cs:9-10). A subscriber running in the same unit of work can load anything else it needs, and a fat payload would go stale between raise and dispatch.
  • Walkthrough: a three-member positional record (LeaderboardOptInChanged.cs:15-19): State, LeaderboardOptInId, UserId. Raised from three call sites on the aggregate, and each one tells you something: Create raises Added (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:75), reactivation of a previously soft-deleted opt-in raises Added again rather than a distinct "reactivated" fact (LeaderboardOptIn.cs:98), and the soft delete raises Deleted (LeaderboardOptIn.cs:114). A subscriber therefore never has to know whether a row is new or revived, which is exactly the information a leaderboard projection does not care about.
  • Why it's built this way: opting out of the leaderboard is a privacy action (ADR-005 distinguishes soft delete from erasure), so it must be a first-class fact and not a silently flipped flag.
  • Where it's used: raised by the aggregate and asserted by the domain tests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Domain.Tests/Points/LeaderboardOptInTests.cs:76,105,139).
  • Caveats / not-in-source: no handler subscribes to this event anywhere in current ADC source. It is a published extension point that the framework's DomainEventDispatcher will deliver the moment one is written, not shipped behavior. Contrast UserSessionBookmarkChanged below, which does have a subscriber.

ManualCheckInRequestValidator

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.ManualCheckIn · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/ManualCheckIn/ManualCheckInRequestValidator.cs:9 · Level 2 · class (sealed)

  • What it is: the validator for the organizer's manual fallback. Structurally the same four rules as CheckInAttendeeRequestValidator, with one substitution.
  • Depends on: ManualCheckInRequest, CheckInScope, the UserIdentifierType / EventIdentifierType aliases; externals: FluentValidation.
  • Concept reinforced: the conditional rule and the enum check taught in the previous section apply unchanged, including the identical .When(x => x.Scope == CheckInScope.Session) guard and the same comment about the event staying required for both scopes (ManualCheckInRequestValidator.cs:28-33).
  • Walkthrough: the one line that differs is the first rule (ManualCheckInRequestValidator.cs:13-16): UserId not default(UserIdentifierType) with code "CheckIn.UserId.Required", where the scan path validates a badge Credential instead. That single substitution is the whole difference between the two paths at this layer, and it is the security-relevant one: on this endpoint the attendee is named by the caller, so the endpoint itself is organizer-only rather than the badge row being the authority. Scope (:18-21), EventId (:23-26) and the conditional SessionId (:29-33) are line-for-line the scan validator's rules, messages and error codes included.
  • Why it's built this way: identical error codes across the two paths mean a client shows the same message whichever way an organizer records the check-in, which matters because the two surfaces sit side by side on the same organizer screen.
  • Where it's used: runs ahead of ManualCheckInHandler (CheckInsController.cs:101,112).

PointsEntryChanged

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Points.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/DomainEvents/PointsEntryChanged.cs:17 · Level 2 · record (sealed)

  • What it is: the domain event raised when one award lands in the points ledger. It carries the whole award: who earned it, for what activity, and how many points.
  • Depends on: BaseDomainEvent, DomainEntityState, PointsActivityType, the PointsEntryIdentifierType / UserIdentifierType aliases.
  • Concept reinforced, the same BR-60 shape as LeaderboardOptInChanged, with an honest caveat in the doc. [Rubric §8, Data Architecture] (assesses whether the storage model matches the domain's mutability) and [Rubric §4, DDD]. The comment (PointsEntryChanged.cs:8-10) says the ledger is append-only, so in practice State is always Added, and that the discriminator is carried anyway to keep one event shape across the module. That is a deliberate small redundancy in favor of consistency, and the file says so rather than leaving a reader to wonder why a never-deleted entity carries a delete state.
  • Walkthrough: five positional members (PointsEntryChanged.cs:17-23): State, PointsEntryId, UserId, ActivityType, Points. Unlike the other two events in this unit, this one carries the value, because the awarded points are a SNAPSHOT of what the rule was worth at earn time (see PointsAwarder) and cannot be re-derived from configuration afterwards. Raised exactly once inside the PointsEntry.Create factory, after the four invariants pass (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:96-101).
  • Why it's built this way: the value is the payload a future subscriber (a live points ticker, an audit trail) would otherwise have to guess at, and guessing it from today's PointsSettings would give the wrong answer for any award earned before a mid-conference retune. Note the entry's own Id is still default when the event is captured (PointsEntry.cs:91-97, the identity is generated by the INSERT), so the useful correlation keys are the user and the activity, not the entry id.
  • Where it's used: raised by PointsEntry and asserted by the domain tests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Domain.Tests/Points/PointsEntryTests.cs:55,65).
  • Caveats / not-in-source: current source contains no subscriber to this event.

SelfHttpWarmupTask

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

  • What it is: the Engagement service's startup warm-up. Once Kestrel is listening it replays the hot bookmarks read against this host's own endpoint so routing, authentication, and the middleware pipeline are JIT-compiled before a real attendee request arrives.
  • Depends on: SelfHttpWarmupTaskBase from MMCA.Common.Aspire.Warmup; externals: ASP.NET Core (IServer, IHostApplicationLifetime, IHostEnvironment), IConfiguration, ILogger<T>.
  • Concept reinforced, the cold-start warm-up task (ADR-025). [Rubric §12, Performance & Scalability] (assesses whether first-request latency after a restart or scale-out is managed rather than paid by a user) and [Rubric §13, Observability & Operability] (assesses that readiness reflects an instance's real ability to serve). The class doc states the contract (SelfHttpWarmupTask.cs:6-17): the base class discovers the bound cleartext address, waits for the host lifetime, and issues the configured GETs; the runner registered by AddWarmupReadiness() inside AddServiceDefaults() holds /health/ready not-ready until the task has had its chance, so the orchestrator does not route traffic into a cold instance. Failures are logged and fall back to lazy warm-up on the first real request rather than blocking startup.
  • Walkthrough: a primary-constructor class forwarding all five dependencies to the base (SelfHttpWarmupTask.cs:23-29) and overriding three members.
    • Paths (SelfHttpWarmupTask.cs:33-36), one entry: bookmarks?pageNumber=1&pageSize=10. The comment above it (:31-32) ties the string to the attendee bookmark list under the module-wide [Authorize] policy (BookmarksController, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:33).
    • Name => "SelfHttpWarmup" (:39) and WarmupPaths => Paths (:42), the base's two abstractions.
    • RequireSuccessStatusCode => false (:49), the interesting override. The controller requires an authenticated caller, so the unauthenticated self-request is refused by design. The XML comment (: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 treating a 401 as a failure would log a spurious warning on every startup.
  • Why it's built this way: the class doc (SelfHttpWarmupTask.cs:9) draws the contrast with Conference's warm-up, which also populates an output cache. This service caches nothing (its base output-cache policy is NoCache, MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:152), so the task buys pipeline JIT only, and it says so rather than pretending to prime a cache it cannot reach. The Identity service ships a same-named task with the same posture over its own hot read (SelfHttpWarmupTask).
  • Where it's used: registered with services.AddWarmupTask<SelfHttpWarmupTask>() in the Engagement service host (Program.cs:158), where the surrounding comment (Program.cs:154-157) 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 supplies only the paths and the status-code policy.

UserSessionBookmarkChanged

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.UserSessionBookmarks.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/DomainEvents/UserSessionBookmarkChanged.cs:21 · Level 2 · record (sealed)

  • What it is: the domain event the UserSessionBookmark aggregate raises when a bookmark is created, reactivated, or soft-deleted. It is the one event in this unit with a live subscriber.
  • Depends on: BaseDomainEvent, DomainEntityState, the UserSessionBookmarkIdentifierType / UserIdentifierType / SessionIdentifierType aliases.
  • Concept introduced, the identity hole in a create-time domain event. [Rubric §4, DDD] and [Rubric §8, Data Architecture] (assesses whether the code accounts for what the database, not the object graph, actually decides). The BR-60 single-event shape is the same one LeaderboardOptInChanged teaches, but this file adds a lesson those two do not. The BookmarkId parameter doc (UserSessionBookmarkChanged.cs:12-18) says the field is ZERO whenever a brand-new bookmark raises Added: identity is generated by the INSERT, the positional record captures the value before that INSERT runs, and nothing re-stamps it afterwards. A reactivated bookmark does carry its real id, because that row already exists. The consequence is stated in the same doc and is the part to remember: handlers correlate on UserId and SessionId, which are both set before the event is raised, and never on BookmarkId. That is a general trap with value-captured events over database-generated keys, not a quirk of this aggregate.
  • Walkthrough: four positional members (UserSessionBookmarkChanged.cs:21-26): State, BookmarkId, UserId, SessionId. Three raise sites, and the shape earns its keep at each: Create raises Added with the still-zero id and a comment saying so (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:55-57); Reactivate raises Added again, and only when Undelete() succeeded (UserSessionBookmark.cs:69-73), which is BR-135 in one line, so a subscriber counting bookmarks sees "one more bookmark exists" for both a fresh star and a revived one; Delete raises Deleted, and only when the soft delete actually flipped (UserSessionBookmark.cs:84-88), so a second delete of an already-deleted bookmark raises nothing and subscribers cannot observe a duplicate fact.
  • Why it's built this way: carrying SessionId as well as UserId means a subscriber can act on the session dimension without loading the aggregate, which is exactly what the one subscriber does.
  • Where it's used: UserSessionBookmarkCacheEvictionHandler subscribes as IDomainEventHandler<UserSessionBookmarkChanged> (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:43-46) and broadcasts an output-cache eviction to Conference so its cached session reads stop serving a stale bookmark count. Its doc explains why the DOMAIN event is the right hook (:23-30): the create path has a bespoke handler, but the delete path runs on the framework's generic DeleteEntityCommand and has no ADC handler at all, whereas the aggregate raises this one event on all three paths that move a count. Also raised in the domain tests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Domain.Tests/UserSessionBookmarks/UserSessionBookmarkTests.cs:45,58,77,105).
  • Caveats / not-in-source: the per-session bookmark count Conference consumes is served by a live query in BookmarkCountService, not by projecting this event; the subscriber only invalidates Conference's cache of that number. Do not read the event as the backing mechanism for the count itself.

CurrentEventNotificationScopeProvider

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Notifications · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Notifications/CurrentEventNotificationScopeProvider.cs:30 · Level 2 · sealed partial class

  • What it is: ADC's implementation of the framework's notification scope hook. It answers "which conference event is in focus right now" as the opaque string event:{EventId}, so a notification sent for an event that has since ended stops surfacing once the next event becomes the current one.
  • Depends on: INotificationScopeProvider (the framework contract it implements), NotificationScopeKey (the canonical key formatter), ILiveEventUIService and LiveEventContext (the resolution source). Externals: TimeProvider, System.Threading.Lock, ILogger<T> with source-generated [LoggerMessage] methods.
  • Concept introduced, the app-supplied scope over a framework-owned mechanism. [Rubric §3, Clean Architecture] and [Rubric §12, Performance & Scalability] assess whether a cross-cutting concern is expressed as a framework extension point the application fills in rather than as application logic baked into the framework. MMCA.Common knows notifications carry an optional scope key (ADR-024) and nothing more; the meaning of that string is ADC's business, and the type doc says so directly (CurrentEventNotificationScopeProvider.cs:11-12: to the framework it is just an opaque string).
  • Concept introduced, failing closed on a view filter. [Rubric §11, Security]. The framework contract is blunt about the direction of failure: implementations must never throw, and in an application whose notifications are all scoped they must fail closed, returning the last known key rather than null, because null on that contract means "unscoped" and silently widens the view to every notification (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:9-14). ADC is such an application, so this provider never answers null. The remarks block (:18-24) spells out the consequence: an instance that has never resolved an event answers with the UnresolvedScopeKey sentinel, event:0 (:38), a well-formed key (it satisfies NotificationScopeKey.Pattern, so the hub's channel-key guard accepts it) that no row carries, so the reader sees an empty inbox instead of everyone's.
  • Walkthrough:
    • The primary constructor (:29-32) takes the live-event service, a TimeProvider and a logger, and the class declares INotificationScopeProvider. UnresolvedScopeKey is composed from NotificationScopeKey.EventPrefix (:38, and MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:23) so the sentinel cannot drift from the prefix the pattern at NotificationScopeKey.cs:32 accepts. CacheDuration is five minutes (:40).
    • The cache is two fields plus a Lock (:45-47). The comment above them (:42-44) is the concurrency reasoning worth reading: the lock guards the two fields only, the refresh runs outside it (a Lock cannot be held across an await), so two callers racing an expired cache both fetch. That is accepted deliberately, because the events read is idempotent and both callers converge on the same key, which beats serializing every poll behind a gate.
    • GetCurrentScopeKeyAsync(ct) (:50) takes the lock, returns the cached key while it is still fresh (:52-58), then resolves outside the lock: liveEventService.GetCurrentEventAsync(ct) (:63) and, when an event comes back, builds the key through NotificationScopeKey.ForEvent (:66), which formats the identifier under CultureInfo.InvariantCulture (NotificationScopeKey.cs:37-38) so a culture with non-ASCII digits cannot produce a key the pattern rejects.
    • The two unresolved paths log and fall through: no current published event logs at Information (:70, :134-135), a thrown lookup logs at Warning (:73-76, :131-132), and both leave scopeKey null.
    • The fail-closed answer (:78-83) returns LastResolvedOrSentinel() (:123-129), the last key this instance resolved or event:0. Nothing is cached on that path and the expiry is left alone (:120-121), so the very next call retries the lookup rather than serving a stale answer for a whole window.
    • The write-back (:85-89) caches only a successful resolution, stamping _cacheExpiresOn five minutes out from the injected clock.
    • GetCurrentScopeDisplayNameAsync(ct) (:104-116) is the optional half of the contract (a default interface method, INotificationScopeProvider.cs:39): it returns the current event's name for a send surface to caption its target (:108-109), and fails closed the way that member defines it, by returning null so a missing caption hides information rather than stating the wrong audience (:113-114, logged at Debug via :137-138).
  • Why it's built this way: the caching is not premature. Both notification HTTP services ask for the scope on every call (NotificationInboxService.cs:180, PushNotificationService.cs:34) and NotificationBell polls the unread count on a PeriodicTimer whose default interval is 30 seconds (NotificationBell.razor.cs:92, MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/NotificationBellOptions.cs:22), so an uncached provider would put an events fetch behind every poll. [Rubric §12, Performance & Scalability] and [Rubric §31, Cost/FinOps] both land on that arithmetic. [Rubric §13, Observability & Operability]: the three [LoggerMessage] methods are source-generated and each message states what the operator loses, not just that something failed. The registration is the other deliberate detail: services.AddScoped<INotificationScopeProvider, CurrentEventNotificationScopeProvider>() (MMCA.ADC.Engagement.UI/DependencyInjection.cs:73) uses plain AddScoped so it overrides the NullNotificationScopeProvider that AddNotificationUI registers with TryAddScoped (MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:24), which keeps the two calls order-independent (DependencyInjection.cs:61-62); the line above it (:65) has to TryAddSingleton(TimeProvider.System) because no UI head registers a TimeProvider (the heads never call AddInfrastructure, :63-64).
  • Where it's used: resolved by the framework's notification services listed above, and by the send page for its targeting caption (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:77); never called directly by an ADC page. Covered by CurrentEventNotificationScopeProviderTests.
  • Caveats / not-in-source: the cache is per instance and the registration is scoped, so on Blazor Server the window is per circuit, not per user or per process. How long a stale key can survive an event rollover is therefore bounded by the five-minute window, not by any invalidation signal.

IPointsAwarder

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/IPointsAwarder.cs:19 · Level 3 · interface

  • What it is: the single write path into the points ledger. One method: award the configured points for one activity, or do nothing when the rule is off or the attendee already earned that exact award.
  • Depends on: PointsActivityType, PointsSubjectKeys (named in the parameter docs), Result, the UserIdentifierType alias.
  • Concept introduced, the deliberately vocabulary-free contract. [Rubric §3, Clean Architecture] (assesses whether abstractions are stated in terms the layer owns rather than in terms of an outer concern) and [Rubric §15, Best Practices & Code Quality]. The doc comment argues the design explicitly (IPointsAwarder.cs:10-17): neither this interface nor the PointsEntry entity names an event, a session, a check-in, or any other ADC concept. An award is a user, an activity, an opaque subject key, and a timestamp. That is what would make lifting the ledger (the entity, this awarder, and the read queries) into MMCA.Common a MOVE rather than a rewrite, and it is the same "extract reusable infrastructure, keep the vocabulary local" instinct the workspace applies everywhere. The ADC-specific part stays in the thin award adapters that translate a module event into an (activity, subjectKey) pair, for example AttendeeCheckedInPointsHandler. [Rubric §1, SOLID] also applies at the interface-segregation level: one method, one reason to call it.
  • Walkthrough: Task<Result> AwardAsync(UserIdentifierType userId, PointsActivityType activity, string subjectKey, DateTime occurredOnUtc, CancellationToken cancellationToken = default) (IPointsAwarder.cs:35-40). The return contract is the part to read carefully (IPointsAwarder.cs:30-34): the result succeeds when the award EXISTS after the call, whether this call created it, an earlier call did, or the rule is configured to award nothing. It fails only when the entry itself is invalid, which the doc calls a caller bug rather than a retryable condition. Callers therefore never need to distinguish "awarded" from "already awarded", which is what lets a broker redelivery run straight through.
  • Why it's built this way: stating idempotency, the per-rule kill switch, and the value snapshot once in the contract (IPointsAwarder.cs:6-9) means each new earn rule is a small adapter rather than a re-derivation of the same three concerns.
  • Where it's used: registered by hand as scoped (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:84, with the comment at :76-82 explaining that a plain service is invisible to the handler convention scan below it) and implemented by PointsAwarder. Consumers resolve it from their own scope: AttendeeCheckedInPointsHandler (.../Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:48), EventFeedbackSubmittedPointsHandler (.../EventFeedbackSubmittedPointsHandler.cs:38), SessionFeedbackSubmittedPointsHandler (.../SessionFeedbackSubmittedPointsHandler.cs:40), and SessionQuestionSubmittedPointsHandler (.../Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:78).

IBookmarkUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Bookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Bookmarks/IBookmarkUIService.cs:11 · Level 3 · interface

  • What it is: the UI-side contract for the Bookmarks API resource as the Engagement UI consumes it, create, list-by-user, and delete.
  • Depends on: CreateBookmarkRequest, UserSessionBookmarkDTO, Result, and the UserIdentifierType / EventIdentifierType / UserSessionBookmarkIdentifierType aliases. Externals: BCL Task.
  • Concept introduced, the UI service abstraction. [Rubric §18, UI Architecture] assesses whether components talk to typed service interfaces rather than raw HttpClients. Components depend on this interface, not on BookmarkService, so a page can be tested against a fake while the transport concerns (auth headers, retry policy, error translation) stay behind the contract in AuthenticatedServiceBase. Every interface in this unit follows the same shape.
  • Concept introduced, Result all the way to the page. [Rubric §9, API & Contract Design]. The type doc states the rule for the whole family (IBookmarkUIService.cs:6-10): every member answers with a Result, so a server refusal is data the page renders, never an exception it has to catch. That is what lets a component write if (!result.TryGetValue(out var value)) and branch, the pattern taught in group 01 and used verbatim by every consumer page in this module.
  • Walkthrough (IBookmarkUIService.cs:11-33): CreateAsync(request, ct) returns Result<UserSessionBookmarkDTO> (:14-16); GetUserBookmarksAsync(userId, eventId?, pageNumber, pageSize, ct) returns a Result over a tuple of the page items plus the total count (:19-24), with eventId optional and paging defaulted to pageNumber = 1 / pageSize = 10; DeleteAsync(id, ct) returns a bare Result (:30-32), and its doc records the deliberate change of shape (:26-29): a bookmark that is not there is an ErrorType.NotFound failure rather than the old false, so a caller can tell "already gone" from "the call failed" without inventing its own convention.
  • Why it's built this way: the identifier-keyed shape (UserSessionBookmarkIdentifierType on delete, userId on read) mirrors the REST resource one-for-one, which keeps the service a thin translation of the endpoint rather than a second place where bookmark rules could accumulate.
  • Where it's used: implemented by BookmarkService (BookmarkService.cs:17) and registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:33).
  • Caveats / not-in-source: no page or component in the repository injects this interface today. The session-level surfaces (the star toggle on the public session list and detail pages) go through the sibling ISessionBookmarkUIService instead, which is the one SessionBookmarkUIService implements. This contract stays registered and implemented, so it is available, but its consumers are not in source.

AttendeeCheckedIn

MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.IntegrationEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:24 · Level 3 · record (sealed)

  • What it is: the cross-module announcement that an attendee was checked in. One record carries every check-in shape: an organizer badge scan, the manual organizer fallback, a self-recorded sponsor booth visit, and a room check-in.
  • Depends on: BaseIntegrationEvent (the base it derives from, AttendeeCheckedIn.cs:32) and the module identifier aliases (UserIdentifierType, EventIdentifierType, SessionIdentifierType, SponsorIdentifierType). It names CheckInScopeNames only in its documentation, never as a type in its signature. It carries an explicit wire name through [EventName("Engagement.AttendeeCheckedIn.v1")] (:23), so the contract on the broker does not move when the CLR type is renamed or relocated. Externals: DateTimeOffset.
  • Concept introduced, the wire contract written for consumers you cannot redeploy. [Rubric §9, API & Contract Design] assesses whether contracts are versionable rather than merely correct today, and this record makes three deliberate choices for that. First, Scope is a string (:26), not the CheckInScope enum: the doc comment (:11-14) states the reason, which is that adding a scope later stays an additive change for a consumer that has not been rebuilt, where an unknown enum member would deserialize into a value the consumer's own enum cannot name. Second, SponsorId is optional and last (:31), with the doc comment (:22) recording that placement so a consumer keeps deserializing payloads written before sponsor visits existed. Third, the v1 suffix in the event name (:23) makes the version part of the routed contract rather than an implicit property of the assembly. [Rubric §6, CQRS & Event-Driven] covers the delivery half: this is an integration event, not a domain event, so it does not merely dispatch in process. It is captured with the row that produced it and published by the outbox processor (ADR-003). [Rubric §7, Microservices Readiness] applies because the payload is all scalars: nothing on it can only be resolved inside the Engagement process.
  • Walkthrough: seven positional members. UserId (:25) is the attendee. Scope (:26) is one of the CheckInScopeNames string constants. EventId (:27) is set for every scope, which is what lets a consumer bucket any check-in by conference without a lookup. SessionId (:28) is nullable and set only for a Session scope. CheckedInByUserId (:29) records who performed the check-in, an organizer for a scan and the attendee themselves for a self-recorded visit (:20), which is what keeps self-recorded rows distinguishable downstream. CheckedInOn (:30) is the recorded instant. SponsorId (:31) is the trailing optional member, defaulted to null.
  • Why it's built this way: the doc comment (:6-10) ties the event to the aggregate factory: it is added inside CheckIn.Create (CheckIn.cs:112-119) rather than in a handler, so the outbox captures it in the same transaction as the row. That gives the property the points economy depends on: a persisted check-in has published exactly one event, and a duplicate scan, which short-circuits before the factory, publishes none. Note the raise call is AddDomainEvent (CheckIn.cs:112): the framework's dual-dispatch treats an IIntegrationEvent added to an aggregate as outbox-bound, so an aggregate raises both kinds through one API. See ADR-072 for the surrounding badge-and-points decision.
  • Where it's used: raised by CheckIn (CheckIn.cs:112), consumed by AttendeeCheckedInPointsHandler. The Engagement service subscribes its own receive endpoint to this type (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:286), which the surrounding comment (:268-277) calls out as ADC's first broker self-consumption: the message leaves this process through the broker and comes back to it, except under the InProcess test transport, where it is delivered in process so the integration tier exercises the same handler without a broker.

LiveChannelPublishProcessor

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Live · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30 · Level 3 · class (sealed)

  • What it is: the single background reader that drains LiveChannelPublishQueue and forwards each queued broadcast to ILiveChannelPublisher. It is the piece that keeps live-layer broadcasts (poll opened, results changed, question approved) off the command request path.
  • Depends on: LiveChannelPublishQueue (the concrete queue, injected as itself for its Reader, LiveChannelPublishProcessor.cs:31), ILiveChannelPublisher resolved per item (:51), and BestEffort (:45). Externals: BackgroundService from Microsoft.Extensions.Hosting, IServiceScopeFactory, and ILogger.
  • Concept introduced, the single-reader hosted drain. [Rubric §12, Performance & Scalability] assesses what work sits on the request path: a command handler here never awaits a broadcast, it enqueues, and this worker pays the network cost afterwards. [Rubric §29, Resilience & Business Continuity] is the reason the loop looks the way it does: the publish is best effort (BR-229, ADR-039), so no failure is allowed to escape the drain, and a down or hung Notification peer costs at most the adapter's own deadline per item and can never crash the host or fail a command (:15-20). [Rubric §13, Observability & Operability] covers the diagnostics: the swallow is delegated to BestEffort, so a peer that has quietly stopped accepting broadcasts is countable on the besteffort.dispatch.failed meter rather than being a Warning nobody alerts on (:21-28, and the meter itself at MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:107-110). The queue counts its own backpressure drops separately (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:61-70). [Rubric §29, Resilience, Reliability & Business Continuity] covers the lifetime mismatch this class exists to bridge: a BackgroundService is a singleton while the gRPC publisher adapter is registered scoped, so the worker opens one scope per item (:50-51).
  • Walkthrough
    • The primary constructor (:30-33) takes the queue, an IServiceScopeFactory and a logger. The class is plain sealed, not partial: the logging it would otherwise generate itself lives inside BestEffort.
    • PublishOperationPrefix (:36) is the constant "live-channel-publish:". It is completed per item with the work item's event name (:46) to form the best-effort operation name. The comment above the class (:21-28) explains the cardinality reasoning: the event name is a small fixed set of channel constants and is therefore safe as a metric tag, while the channel key is per session and is deliberately left out because fanning the tag out tells an operator nothing they can act on.
    • ExecuteAsync (:39) is one await foreach over queue.Reader.ReadAllAsync(stoppingToken) (:41). There is exactly one of these loops in the process, and the queue is created with SingleReader = true (LiveChannelPublishQueue.cs:37), which is what makes delivery FIFO and therefore per-session order preserving: successive poll.results-changed tallies cannot arrive out of order (LiveChannelPublishProcessor.cs:11-14).
    • Per item the body is handed to BestEffort.ExecuteAsync (:45-58) with the operation name, the logger, the publish lambda and the stopping token. Inside the lambda an async DI scope is created (:50), the publisher is resolved from it (:51), and PublishAsync is awaited with the work item's channel key, event name and pre-serialized payload plus the token the helper passes in (:52-56).
    • Cancellation is separated from failure, and the split is shared rather than local: BestEffort rethrows the caller's own cancellation instead of recording it as a failure (BestEffort.cs:59-64), and this loop catches that rethrow when stoppingToken.IsCancellationRequested and returns quietly (:60-65).
  • Why it's built this way: the enqueue side cannot block and cannot fail, so backpressure has to be resolved somewhere. It is resolved in the queue, not here: the channel is bounded at 1024 items with BoundedChannelFullMode.DropOldest (LiveChannelPublishQueue.cs:18, :36), which chooses the freshest broadcast over the oldest when the drain falls behind, because live channel events are ephemeral. A DropOldest write always succeeds, so the queue registers an itemDropped callback (LiveChannelPublishQueue.cs:40, :61-65) as the only way a discard becomes observable. This worker's job is only to be the one reader that gives that channel its ordering guarantee, and to make its swallowed failures countable rather than merely logged.
  • Where it's used: registered as a hosted service by the module's infrastructure registration (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/DependencyInjection.cs:21), so it starts with any host that boots the Engagement module. Its producers are the live-layer handlers that hold ILiveChannelPublishQueue (for example MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:23 and .../SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:29). In the deployed topology the publisher is the gRPC adapter targeting Notification's dedicated Http2 endpoint (ADR-012). Covered by LiveChannelPublishProcessorTests.
  • Caveats / not-in-source: whether a given deployment actually reaches a Notification peer is configuration and runtime, not source. Nothing in this file retries a failed publish; a dropped or failed broadcast is gone, which is the stated contract rather than an omission.

BookmarkService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Bookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Bookmarks/BookmarkService.cs:15 · Level 4 · sealed class

  • What it is: the HTTP implementation of IBookmarkUIService, talking to the bookmarks resource through the Gateway with authenticated, retried requests. The endpoint prefix is the const Endpoint = "bookmarks" (BookmarkService.cs:19). The class doc (:11-13) records why it does not derive from the generic CRUD base: the bookmark endpoints do not fit that shape.
  • Depends on: IBookmarkUIService (the contract it satisfies), AuthenticatedServiceBase (base class), ITokenStorageService, HttpResultExecutor, ProblemDetailsResultReader, CreateBookmarkRequest, UserSessionBookmarkDTO, PagedCollectionResult<T> (BookmarkService.cs:1-7). Externals: IHttpClientFactory, System.Net.Http.Json, System.Globalization.
  • Concept introduced, the authenticated HTTP UI service. This is the shape every write-capable service in this module follows, so it is worth reading once in full. Four pieces do the work, three inherited and one static:
    • CreateAuthenticatedClientAsync() (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/AuthenticatedServiceBase.cs:51) takes the named "APIClient" from the factory (:53), reads the access token from the circuit-scoped ITokenStorageService and sets the Bearer header (:57-62). An InvalidOperationException is swallowed (:64-67), which is the SSR prerender pass where JS interop is not available yet: the call proceeds unauthenticated rather than throwing. [Rubric §26, Front-End Security] assesses how a client handles credentials; the token is never persisted by this class, it is fetched per call from the storage abstraction (ADR-051).
    • RetryPolicy (AuthenticatedServiceBase.cs:25, built at :131-134) is a static Polly policy: 3 retries with exponential backoff (2s, 4s, 8s per DefaultBackoff at :114-116) plus up to one second of jitter so a room full of clients does not re-converge on the same instant. Retryable means 5xx except 501 and 505, plus 408 and 429 (:100-109). Its onRetry disposes each retried response (:134), because Polly hands back only the final outcome and every intermediate 5xx would otherwise leak its content buffer. [Rubric §29, Resilience & Business Continuity] assesses whether transient failures are absorbed close to where they happen; this is the client half of that.
    • ProblemDetailsResultReader (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:58) converts the response into a Result. It understands four payload shapes (:22-49): the MMCA error array (lossless, ErrorType preserved), the ASP.NET validation dictionary, plain RFC 9457 problem details, and a non-JSON or empty body, which it synthesizes into one error coded Http.{status} (:65). [Rubric §9, API & Contract Design] assesses whether the error contract survives the transport, and this is where it is decoded.
    • HttpResultExecutor.ExecuteAsync (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Api/HttpResultExecutor.cs:52, :87) wraps the whole delegate and converts the absence of a response into a failure: a refused connection, a DNS failure, a dropped socket, a JsonException (:121-122), each becoming Http.TransportFailure (:34). A client timeout becomes Http.Timeout (:37), while the caller's own cancellation is deliberately rethrown (:65-68) so a disposed component's abort is not reported as an error to render. The two halves together are what make a service method honestly typed as returning a Result (:11-16).
  • Walkthrough (members in call order)
    • CreateAsync (:22-36) POSTs the CreateBookmarkRequest as JSON to bookmarks and reads the created UserSessionBookmarkDTO back through the reader (:34). There is no try/catch and no EnsureSuccessStatusCode: every server answer is a Result, every transport fault is caught by the executor.
    • GetUserBookmarksAsync (:39-74) builds its query string by hand into a List<string> with string.Create(CultureInfo.InvariantCulture, ...) per parameter (:48-53) and appends eventId only when the optional filter is supplied (:55-56). Invariant formatting is not decoration here: these identifiers are interpolated into a URL, and a culture-sensitive numeric format would produce a query the API cannot bind. The response is read as a PagedCollectionResult<T> and then Mapped, on the success branch only, into a (Items, TotalItems) tuple using PaginationMetadata.TotalItemCount (:68-72). Map is the point worth noticing: the projection runs only when the read succeeded, so no null check is needed and a failure passes through carrying its original errors.
    • DeleteAsync (:77-94) DELETEs by bookmark id and returns the valueless Result (:92). The comment above it (:89-91) records the behavior change deliberately: a bookmark that is not there answers 404, which now arrives as a NotFound failure rather than the old false, so "nothing to remove" stays distinguishable from "the remove failed".
  • Why it's built this way: the class doc (:11-13) states it directly, the bookmark endpoints (create with an owner in the body, list by user, delete by bookmark id) do not fit the generic EntityServiceBase CRUD shape, so this service spells out the three calls the BookmarksController contract actually offers (BookmarksController.cs:50, :83, :122). The whole controller sits behind the EngagementFeatures.SessionBookmarks gate (BookmarksController.cs:32, see EngagementFeatures and ADR-031).
  • Where it's used: registered scoped as IBookmarkUIService in the module's UI wiring (MMCA.ADC.Engagement.UI/DependencyInjection.cs:33). [Rubric §14, Testability]: covered directly by BookmarkServiceTests.
  • Caveats / not-in-source: no page injects IBookmarkUIService today. The only references in the repo are the interface, this implementation and the DI registration; the inline bookmark toggles the UI actually renders go through SessionBookmarkUIService instead. Note also that CreateAsync sends no Idempotency-Key header, so a retried create relies on the server's duplicate guard rather than on idempotency replay (contrast SessionFeedbackService.SubmitAnswersAsync). Finally, all three calls use the retry overload without a CancellationToken (:30, :62, :86): the token still reaches the HTTP call itself, but Polly's own backoff waits are not cancellable on this path, unlike every other service in this unit.

SessionBookmarkUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Bookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Bookmarks/SessionBookmarkUIService.cs:26 · Level 5 · sealed class

  • What it is: a cross-module HTTP service implementing ISessionBookmarkUIService, used by the Conference UI to read bookmark state and toggle bookmarks inline. Beyond the three HTTP calls, every successful read or mutation also feeds the session-reminder layer.
  • Depends on: ISessionBookmarkUIService, AuthenticatedServiceBase, ITokenStorageService, SessionReminderCoordinator (injected as a concrete class, :28), HttpResultExecutor, ProblemDetailsResultReader, CreateBookmarkRequest, UserSessionBookmarkDTO. Externals: IHttpClientFactory, System.Net.Http.Json, System.Globalization.
  • Concept introduced, the side effect on the write path. [Rubric §19, State Management] assesses where derived client state is kept in step with the server. Local session reminders are exactly that kind of derived state: they are only correct if they track the bookmark set, and the bookmark set can change from any page or any device. Putting the reminder calls inside this one service, rather than in each component that toggles a bookmark, is what makes that hold regardless of entry point. The ordering is the load-bearing part: HTTP first, reminder only after a confirmed success. The class doc adds the guarantee that makes this safe to read (:18-23): nothing here throws for a server answer, the response is read through ProblemDetailsResultReader with the API's own ErrorType intact and transport faults become failures through HttpResultExecutor, so the reminder layer is driven only by outcomes the server actually confirmed. [Rubric §29, Resilience]: reminder failures are swallowed inside the coordinator, so they never surface to the bookmark caller.
  • Walkthrough
    • GetBookmarkedSessionIdsAsync (:33-61) GETs bookmarks/session-ids?userId= and reads a Dictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType> (:46-48), which is why a page can render a bookmark toggle and know the id needed to delete it from one call. It then branches on TryGetValue (:52-55): a failure is re-wrapped with its original errors and returns early, so the reminder call is unreachable on that path. On success it calls reminderCoordinator.ResyncAsync([.. bookmarks.Keys], cancellationToken) (:58) with the comment naming the intent: this response is the authoritative set, so it is the right moment to heal drift caused by another device. The dictionary is finally re-wrapped as the interface's read-only type (:60).
    • CreateBookmarkAsync (:64-90) builds a CreateBookmarkRequest from the two identifiers (:68), POSTs it, and calls NotifyBookmarkAddedAsync(sessionId, ...) only when result.IsSuccess (:84-87).
    • DeleteBookmarkAsync (:93-118) DELETEs by bookmark id and calls NotifyBookmarkRemovedAsync(sessionId, ...) only on a confirmed delete (:112-115). A 404 is a NotFound failure and therefore does not notify: nothing was removed.
    • The coordinator members these three calls use are ResyncAsync (SessionReminderCoordinator.cs:54), NotifyBookmarkAddedAsync (:40) and NotifyBookmarkRemovedAsync (:44).
  • Why it's built this way: the class doc (:12-17) records both halves, the reminder wiring lives here so reminders track bookmarks no matter which page toggled them (ADR-042 Wave 2), and reminder failures never reach the bookmark caller. The coordinator is registered scoped alongside this service (MMCA.ADC.Engagement.UI/DependencyInjection.cs:38) and its comment there states it is a no-op on hosts whose local-notification capability is unsupported, which is why a web-only host pays nothing for this coupling.
  • Where it's used: registered scoped as ISessionBookmarkUIService (MMCA.ADC.Engagement.UI/DependencyInjection.cs:34); resolved optionally by the Conference public pages, which call ServiceProvider.GetService<ISessionBookmarkUIService>() and null-check the result (PublicSessionList at PublicSessionList.razor.cs:41, :86; PublicSessionDetail at PublicSessionDetail.razor.cs:40, :54), and passed down as a nullable parameter to PublicSessionListView (:60). That optional resolution is the extraction story in practice: Conference renders correctly in a host where the Engagement UI module is not registered at all. Covered by SessionBookmarkUIServiceTests.
  • Caveats / not-in-source: the dependency is on the concrete SessionReminderCoordinator, not an interface, so a test of this service has to construct a real coordinator; the test suite does exactly that, assembling an inert one from mocks (SessionBookmarkUIServiceTests.cs:129-136). Whether a reminder is actually scheduled is decided inside the coordinator and SessionReminderPlanner, not here.

PointsAwarder

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:29 · Level 10 · class (sealed partial)

  • What it is: the only implementation of IPointsAwarder. It writes one award idempotently, or writes nothing when the rule is switched off.
  • Depends on: IUnitOfWork, PointsSettings via IOptions<T>, PointsEntry, IUniqueConstraintViolationDetector, Result; externals: IOptions<T>, ILogger<T> with [LoggerMessage].
  • Concept introduced, the configuration kill switch and the value snapshot. [Rubric §17, DevOps & Deployment] (assesses whether operational levers are configuration rather than code), [Rubric §8, Data Architecture], and [Rubric §11, Security] in its anti-abuse sense. Three ideas are stated once here so no earn rule has to restate them. (1) The stored Points value is a SNAPSHOT of the configured award at the moment it was earned (PointsAwarder.cs:12-17), so retuning the economy mid-conference changes what the NEXT award is worth and never rewrites history: a total is always explainable by what the rules said when it was earned. (2) The unique index on (UserId, ActivityType, SubjectKey) is the source of truth for both idempotency and anti-farming (:18-23), which is what makes a replayed broker message and a farmed scan both land exactly once. (3) A configured 0 is a per-rule kill switch that takes one earn rule out of service without a deploy.
  • Walkthrough: four constructor dependencies (PointsAwarder.cs:29-33), one method (:36-86).
    • Kill switch (:43-51): settings.Value.GetPointsFor(activity); when the value is <= 0 it logs at Debug and returns success without writing. GetPointsFor maps each of the six activities to its configured property and returns 0 for anything undefined (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSettings.cs:48-58), so a missing configuration entry and a deliberate zero behave identically. Writing nothing (rather than a zero-value row) keeps the ledger free of entries that would otherwise have to be filtered out of every read.
    • Idempotency pre-check (:55-64): ExistsAsync on (UserId, ActivityType, SubjectKey). The comment (:55-56) enumerates what lands here: a repeat scan, a second answer to the same feedback form, and a broker redelivery. All three leave as a clean success.
    • Create (:66-68): PointsEntry.Create validates its own invariants and returns a Result (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:76-104), a failure of which is the one case that propagates as a failure, because it means the caller passed something invalid.
    • Save with a race backstop (:70-82): the same IsUniqueConstraintViolation filter as CreateBookmarkHandler, but the verdict is the opposite one. Here a lost race is a SUCCESS (:77-81), because "the award the caller wanted now exists" is exactly the outcome requested; a bookmark create, by contrast, is a user-visible action whose duplicate the user should be told about.
    • Four [LoggerMessage] partials (:88-98): rule disabled, already awarded, and lost race all log at Debug, while an actual award logs at Information, so the conference-day log volume tracks real awards rather than no-ops.
  • Why it's built this way: the class doc is explicit that the pre-check "is not the rule" (:21-22). Reading the index as the authority and the pre-check as an optimization is what keeps the concurrent case correct rather than merely unlikely.
  • Where it's used: registered as IPointsAwarder (DependencyInjection.cs:84) and called by the four award adapters listed under IPointsAwarder. Read back by PointsService and by the data-subject export, where the ledger and the check-in history are exported as separate collections (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:51-53,78).

CheckInAttendeeHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.CheckInAttendee · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeHandler.cs:21 · Level 12 · class (sealed partial)

  • What it is: the organizer scanning path. A scanned (or typed) badge credential comes in, the server resolves it to an attendee, and the shared check-in core records the result.
  • Depends on: IUnitOfWork, IEventLiveValidationService, ICurrentUserService plus its CurrentUserServiceExtensions guard, BadgePayload, AttendeeBadge, CheckInProcessor, CheckInResultDTO, IReadRepository<TEntity, TIdentifierType>, ICommandHandler<in TCommand, TResult>; externals: TimeProvider, ILogger<T>.
  • Concept introduced, the anti-oracle error and the credential as the sole authority. [Rubric §11, Security] (assesses whether error responses and logs avoid handing an attacker information) and [Rubric §1, SOLID]. Three security decisions are visible in this small file, each with a comment saying why. First, the attendee is NEVER taken from the request: the credential is the only input and the badge row is the sole authority on whose badge it is, while the organizer comes from the caller's token (CheckInAttendeeHandler.cs:15-20). Second, a malformed payload and a well-formed but unknown credential return the SAME error (:41-46, :53-56, via the shared BadgeNotFound() helper at :77-81), because a scanner able to distinguish them would be an oracle for guessing valid credentials. Third, the scanned credential is deliberately absent from the log line (:83-84): it is a bearer value, and a log carrying it would be a badge anyone with log access could replay.
  • Walkthrough: five constructor dependencies (:19-24), one HandleAsync (:27-75).
    • ArgumentNullException.ThrowIfNull(command) (:31), then the caller guard: currentUserService.RequireUserId("CheckIns.Forbidden") (:33-39). That is the framework's extension(ICurrentUserService) member (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:35-46), which collapses the read-then-null-check-then-fail block into one call returning Result<UserIdentifierType> and defaults the classification to ErrorType.Forbidden. All four check-in handlers in this unit open the same way with the same code string.
    • BadgePayload.TryExtractCredential(command.Credential, out var credential) (:43), a Try shape so a malformed scan is an ordinary branch rather than an exception.
    • Badge lookup through GetReadRepository (:48-52), not GetRepository: nothing about the badge is mutated here, and using the read repository states that. asTracking: false reinforces it.
    • Delegation to CheckInProcessor.ExecuteAsync with badge.UserId (:58-67). Everything from target validation through idempotency to the write lives there, which is why this handler is short.
    • Logging only on success (:69-72), carrying the repeat flag so an operator can see at a glance whether a scan wrote a row or reported an existing one.
  • Why it's built this way: the two organizer entry points differ ONLY in how the attendee is identified, so the identification is all that lives in each handler and the shared rules live once in the processor (CheckInProcessor.cs:10-21), which is a static helper precisely so neither handler gains a dependency it does not own.
  • Where it's used: CheckInsController resolves it as ICommandHandler<CheckInAttendeeRequest, Result<CheckInResultDTO>> (CheckInsController.cs:41,88). The check-in it writes is what later drives AttendeeCheckedInPointsHandler through the AttendeeCheckedIn integration event.

ManualCheckInHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.UseCases.ManualCheckIn · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/ManualCheckIn/ManualCheckInHandler.cs:18 · Level 12 · class (sealed partial)

  • What it is: the organizer's manual fallback for when a badge will not scan. No credential is involved: the attendee is named in the request, and everything after that is the same core the scan path runs.
  • Depends on: IUnitOfWork, IEventLiveValidationService, ICurrentUserService, CheckInProcessor, CheckInResultDTO, ICommandHandler<in TCommand, TResult>; externals: TimeProvider, ILogger<T>.
  • Concept reinforced, the shared core with a per-entry-point difference. [Rubric §15, Best Practices & Code Quality] and [Rubric §11, Security]. Compared with CheckInAttendeeHandler, the credential extraction and badge lookup are simply absent (ManualCheckInHandler.cs:40-49 versus CheckInAttendeeHandler.cs:45-69); command.UserId goes straight into the processor. The doc comment carries the compensating control (ManualCheckInHandler.cs:13-16): an organizer-only endpoint backs it, which is what makes naming another user acceptable here and unacceptable on the attendee-facing flows in this unit. The payoff is stated in the same comment: a manual check-in and a scanned one produce identical rows and events, so nothing downstream needs to know which door the check-in came through.
  • Walkthrough: five constructor dependencies (:17-22), one HandleAsync (:25-56). ArgumentNullException.ThrowIfNull(command) (:29), the same RequireUserId("CheckIns.Forbidden") caller guard as the scan path (:31-37), the delegation to CheckInProcessor.ExecuteAsync (:39-48), and a success-only Information log whose template differs from the scan path only by the word "manually" (:58-64), which is what lets an operator tell the two apart in the logs even though the rows are identical.
  • Why it's built this way: keeping the fallback on exactly the same core avoids the classic drift where the rarely used path slowly stops enforcing a rule the main path gained.
  • Where it's used: CheckInsController (CheckInsController.cs:42,112).

IEventFeedbackUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Feedback · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/IFeedbackUIService.cs:23 · Level 3 · interface

  • What it is: the UI contract for submitting and reading a user's own answers to event feedback questions.
  • Depends on: EventQuestionAnswerDTO, Result, and the EventIdentifierType / QuestionIdentifierType / EventQuestionAnswerIdentifierType aliases. Externals: BCL Task.
  • Concept introduced, POST-as-upsert (BR-107). [Rubric §9, API & Contract Design] assesses how idempotent write semantics are expressed to clients. SubmitAnswerAsync (IFeedbackUIService.cs:26-30) is one method for both the first answer and every edit: the server upserts on (eventId, questionId) for the caller, so the UI never branches on create-versus-update and never has to know whether an answer already exists. That is what lets EventFeedback retry a partially failed submit by pressing the same button, since re-posting an already-saved answer is a no-op. The type doc names the rule outright (:19-22). This is the UI service abstraction of IBookmarkUIService specialized to feedback, with the same "every member answers with a Result" contract.
  • Walkthrough (IFeedbackUIService.cs:23-42): SubmitAnswerAsync(eventId, questionId, answerValue, ct) returns Result<EventQuestionAnswerDTO>, the persisted answer (:26-30); GetMyAnswersAsync(eventId, ct) returns the caller's answers for one event (:33-35), which is what the page pre-fills from (EventFeedback.razor.cs:117-118); DeleteAnswerAsync(eventId, answerId, ct) removes one by its server-assigned id (:38-41). Note the answer value crosses as a plain string: rating and text questions share one transport shape and the question type decides how it is read back.
  • Why it's built this way: it is kept as its own type rather than folded into one generic contract with its session twin ISessionFeedbackUIService, because the answer DTOs and the identifier aliases differ and a shared generic would buy nothing at three members apiece.
  • Where it's used: implemented by EventFeedbackService (EventFeedbackService.cs:16), registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:42), consumed by EventFeedback (EventFeedback.razor.cs:22, submitting at :194).

IPointsUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Points/IPointsUIService.cs:14 · Level 3 · interface

  • What it is: the UI contract for the points game surfaces: the attendee's own ledger, the leaderboard and its opt-in switch, and the organizer rollup.
  • Depends on: MyPointsDTO, LeaderboardEntryDTO, PointsOverviewDTO, Result. Externals: BCL Task. The UI service abstraction itself is taught on IBookmarkUIService.
  • Concept introduced, the server-owned identity on a write. [Rubric §11, Security] assesses whether a client can influence data it should not own. SetLeaderboardParticipationAsync(participate, ct) (:41) takes a single boolean and nothing else, and the doc says why (:35-38): the published name is taken from the caller's token server-side, never from this call. A client that could pass its own display name could publish someone else's, so the parameter simply does not exist. The same discipline shapes GetMyPointsAsync (:23-26), which carries no user identifier at all: "mine" is whoever the bearer token says it is.
  • Concept, opt-in as the default privacy posture. [Rubric §30, Compliance, Privacy & Data Governance] assesses how personal data reaches a public surface. Points accrue for every attendee, but the leaderboard shows only those who opted in, under the name they published at opt-in (:28-31), and the opt-in is an explicit write the attendee makes. [Rubric §9, API & Contract Design] covers the rest of the shape: like every service in this module, every member answers with a Result, so a refusal is data the page renders and only the caller's own cancellation still propagates (:9-12).
  • Walkthrough (IPointsUIService.cs:14-51):
    • GetMyPointsAsync(pageNumber = 1, pageSize = 20, ct) (:23-26) returns the running total, the opt-in state and one page of awarded entries, newest first, in a single MyPointsDTO. The three concerns travel together because the "my points" page renders them as one screen.
    • GetLeaderboardAsync(ct) (:33) returns the whole public leaderboard as one list, with no paging parameters: it is a short, capped list by nature.
    • SetLeaderboardParticipationAsync(participate, ct) (:41) joins or leaves, returning a bare Result because there is nothing to read back.
    • GetPointsOverviewAsync(recentCount = 20, ct) (:48-50) is the organizer rollup, with the recent-entry count as the only knob; organizer only (:43-45).
  • Why it's built this way: one interface for four related reads and one write keeps the whole points vocabulary and its single points resource (PointsService.cs:20) in one place, in the same shape as ICheckInUIService does for check-in (ADR-072 defines the check-in and points model together).
  • Where it's used: implemented by PointsService (PointsService.cs:16-18), registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:51), and injected into two pages: MyPoints (MyPoints.razor.cs:21, reading at :75 and :85 and writing the opt-in at :117) and OrganizerPointsOverview (OrganizerPointsOverview.razor.cs:24, reading at :46).

IQuestionLookupService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Feedback · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/IFeedbackUIService.cs:11 · Level 3 · interface

  • What it is: a small read contract that loads the feedback questions to render, filtered by whether they apply to an Event or a Session.
  • Depends on: QuestionDTO, a Conference-owned contract (IFeedbackUIService.cs:2), and Result. Externals: BCL Task.
  • Concept, the cross-module UI read. [Rubric §18, UI Architecture] and [Rubric §7, Microservices Readiness]. Feedback lives in Engagement but the questions are Conference-owned, so this contract lets the Engagement UI pull them through the same authenticated HTTP layer without a compile-time reference to Conference internals: only the shared QuestionDTO crosses the boundary. This is a different trade-off from the mirrored shape on NowNextSessionInfo, and the difference is worth noticing: a .Shared DTO is a published contract project both sides may reference, while a mirror is what you write when you do not want the reference at all.
  • Walkthrough (IFeedbackUIService.cs:11-17): one method, GetQuestionsAsync(questionEntity, ct), where questionEntity is the string discriminator ("Event" or "Session"), returning Result<IReadOnlyList<QuestionDTO>>. The discriminator is a bare string rather than an enum, so a typo at a call site is a runtime empty list, not a compile error; both call sites pass a literal (EventFeedback.razor.cs:104).
  • Why it's built this way: keeping the question read on its own interface rather than duplicating it on both feedback services means one implementation serves both pages, and the discriminator stays the only difference between the two calls.
  • Where it's used: implemented by QuestionLookupService (QuestionLookupService.cs:15), registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:41), consumed by both EventFeedback (EventFeedback.razor.cs:21, :104-105) and SessionFeedback (SessionFeedback.razor.cs:20) to build their question lists.

ISessionFeedbackUIService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Feedback · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/IFeedbackUIService.cs:48 · Level 3 · interface

  • What it is: the UI-side contract for session feedback, four members covering the whole attendee-facing lifecycle of an answer: submit one, submit a whole form at once, read back what the caller already answered, and remove an answer (IFeedbackUIService.cs:48-76). It is the third of three interfaces declared in one file, beside IQuestionLookupService (:11) and IEventFeedbackUIService (:23).
  • Depends on: Result and Result<T> from MMCA.Common.Shared.Abstractions (:4), SessionQuestionAnswerDTO and QuestionDTO from the Conference contracts (:1-3), plus the SessionIdentifierType, QuestionIdentifierType and SessionQuestionAnswerIdentifierType aliases. No BCL dependency beyond Task and CancellationToken.
  • Concept introduced, the Result-typed client contract. Every member here answers with a Result rather than a nullable DTO or a bare Task, and the class doc states it outright (:46). [Rubric §9, API & Contract Design] assesses whether a client abstraction preserves the distinctions the server makes: a nullable return collapses "no such answer", "you are not allowed" and "the network is down" into one null, while a Result keeps the server's own ErrorType and error code intact all the way to the page. [Rubric §24, Forms/Validation/UX Safety] is the front-end consequence: a feedback form can render the server's validation message for the field that failed instead of a generic banner. The pattern itself is taught in the primer and ADR-013; what is new at this layer is that the transport now speaks it too, through ProblemDetailsResultReader and HttpResultExecutor.
  • Walkthrough
    • SubmitAnswerAsync (:51-55) takes the session, the question and the answer string, and returns the persisted SessionQuestionAnswerDTO. It is a POST-as-upsert (BR-107, :46): the caller never has to know whether it is creating a first answer or replacing an existing one.
    • SubmitAnswersAsync (:61-64) is the batch form, taking IReadOnlyList<(QuestionIdentifierType QuestionId, string AnswerValue)> and returning every saved answer. Its doc comment carries the contract that matters most (:57-60): the server applies the form atomically, so the result is all-or-nothing. That is the difference between a form that half-saves on a flaky connection and one that does not.
    • GetMyAnswersAsync (:67-69) reads the caller's own answers for a session. No user identifier appears in the signature: the server scopes the read to the token holder.
    • DeleteAnswerAsync (:72-75) removes one answer by identifier, with the owning sessionId alongside it, and returns the valueless Result.
  • Why it's built this way: keeping the contract in the UI layer over Shared DTOs only is what lets SessionFeedback be bUnit-tested against a mock with no HTTP in sight. [Rubric §1, SOLID]: the batch member exists as its own method rather than as an overload with an options flag, because it maps to a genuinely different endpoint with a different guarantee.
  • Where it's used: implemented by SessionFeedbackService and registered scoped (MMCA.ADC.Engagement.UI/DependencyInjection.cs:43); injected by SessionFeedback (SessionFeedback.razor.cs:21), which drives all four members (:122, :226, :259).
  • Caveats / not-in-source: the interface says nothing about idempotency. That the batch submit carries an Idempotency-Key is an implementation decision, described at SessionFeedbackService.

EventFeedbackSubmittedPointsHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:27 · Level 4 · class (sealed partial)

  • What it is: the award adapter that turns Conference's EventFeedbackSubmitted into an event-scoped points award. It is one of the handlers in this folder and the simplest of them.
  • Depends on: ScopedIntegrationEventHandlerBase<TIntegrationEvent> closed over EventFeedbackSubmitted (:30), which is the framework base that implements IIntegrationEventHandler<in TIntegrationEvent>; IPointsAwarder (resolved from the supplied scope, :38), PointsSubjectKeys (:40) and PointsActivityType (:44). Externals: IServiceScopeFactory and a source-generated [LoggerMessage] (:53-54), which is why the class is partial.
  • Concept introduced, the thin award adapter. [Rubric §3, Clean Architecture] assesses where knowledge sits. The rule the module protects is stated on IPointsAwarder (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/IPointsAwarder.cs:10-17): neither the awarder nor the ledger entity names an event, a session or a check-in, so an award is a user, an activity, an opaque subject key and a timestamp. All the conference vocabulary lives in adapters like this one, which is what would make lifting the ledger into MMCA.Common a move rather than a rewrite. [Rubric §1, SOLID] shows up twice: mapping lives here while idempotency and the per-rule kill switch live once in the awarder, and the two blocks every integration handler would otherwise repeat (the DI scope preamble and the log-and-rethrow envelope) live once in the base (MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:45-63). [Rubric §6, CQRS & Event-Driven] covers the delivery posture: the base propagates exceptions rather than swallowing them, so delivery stays at-least-once and redelivery is normal (ScopedIntegrationEventHandlerBase.cs:26-33), which is why this handler is written to be safely repeatable rather than to guard against a second call.
  • Walkthrough: the class overrides HandleScopedAsync (:33-36) instead of HandleAsync. The base has already null-guarded the event and opened the per-delivery async scope, and hands in that scope's IServiceProvider (ScopedIntegrationEventHandlerBase.cs:47-55), so the body is only its own resolutions plus its own logic: resolve the scoped IPointsAwarder (:38), build the subject key with PointsSubjectKeys.ForEvent(integrationEvent.EventId) (:40), which delegates to NotificationScopeKey.ForEvent and therefore produces the invariant-culture event:{id} string (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSubjectKeys.cs:19-20, MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationScopeKey.cs:37-38), then award PointsActivityType.EventFeedback at the event's own SubmittedOnUtc (:42-47). A rejected award (the awarder returning a failure rather than throwing) is logged at Warning and not rethrown (:49-50), because the awarder only fails when the entry itself is invalid, which is a caller bug rather than a retryable condition (IPointsAwarder.cs:30-34). An exception takes the other path: the base logs it once and lets it propagate so the delivery is retried.
  • Why it's built this way: the class comment (:13-18) states the multiplicity fact that makes this handler safe to keep this simple. Event feedback writes one answer row per question (BR-107), so one submitted form arrives here as several events. Nothing counts them: they all resolve to the same event subject key, and the awarder's uniqueness rule collapses them into a single award. That same property is what makes a broker redelivery a no-op, so no dedupe logic is written twice.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88), which reaches MMCA.Common's singleton registration for every IIntegrationEventHandler<> implementation (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:193-198); the singleton lifetime is exactly why the base opens a scope per delivery (:19-23). The Engagement service subscribes the matching consumer (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:288). Covered by EventFeedbackSubmittedPointsHandlerTests.

SessionFeedbackSubmittedPointsHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:29 · Level 4 · class (sealed partial)

  • What it is: the award adapter for session feedback, mapping Conference's SessionFeedbackSubmitted onto a session-scoped award. The structural twin of EventFeedbackSubmittedPointsHandler.
  • Depends on: the same set as its sibling, with SessionFeedbackSubmitted as the base's closed type (:32) and IPointsAwarder resolved from the scope the base supplies (:40).
  • Concept: the thin award adapter is taught on EventFeedbackSubmittedPointsHandler; the same reasoning applies unchanged. [Rubric §15, Best Practices & Code Quality] is worth naming here: the two files are near-identical and are deliberately kept separate rather than folded into one generic handler, because each is bound to a different event type at the DI boundary and, once ScopedIntegrationEventHandlerBase<TIntegrationEvent> absorbed the scope and error plumbing, what is left to share is five lines of mapping.
  • Walkthrough of what differs: only two lines. The subject key is built with PointsSubjectKeys.ForSession(integrationEvent.SessionId) (:42), producing session:{id} (PointsSubjectKeys.cs:25-26, NotificationScopeKey.cs:43-44), and the activity is PointsActivityType.SessionFeedback (:46). Everything else, the HandleScopedAsync override (:35-38), the awarder resolution (:40), the SubmittedOnUtc timestamp (:48) and the log-and-continue path for a rejected award (:51-52), matches the event handler line for line.
  • Why it's built this way: the class comment (:14-20) is explicit that Conference raises this event once per newly created answer, so one submission normally arrives here as several events, and nothing here counts them: they all resolve to the same session subject key and the awarder's uniqueness rule collapses them into a single award.
  • Where it's used: registered by the convention scan as a singleton (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:193-198); the consumer is wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:287. Covered by SessionFeedbackSubmittedPointsHandlerTests.

SessionQuestionSubmittedPointsHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.DomainEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:51 · Level 4 · class (sealed partial)

  • What it is: the award adapter for session Q and A. It awards PointsActivityType.QuestionAsked the first time an attendee asks a question in a session. It is the one award adapter in the module that rides a domain event rather than an integration event, which makes it the best place in this chapter to see the two delivery models side by side.
  • Depends on: IDomainEventHandler<in TDomainEvent> implemented over SessionQuestionChanged (:53), IPointsAwarder resolved per event (:78), PointsActivityType (:84), PointsSubjectKeys (:85) and DomainEntityState (:60). Externals: IServiceScopeFactory, ILogger, and four source-generated [LoggerMessage] methods (:100-110), which is why the class is partial.
  • Concept introduced, choosing at-most-once on purpose. [Rubric §6, CQRS & Event-Driven] assesses whether a delivery guarantee is a decision or an accident. Its siblings in Points/IntegrationEventHandlers all consume outbox-published integration events, which are at-least-once and survive a crash. This one subscribes to an in-process domain event, and the class comment (:30-39) argues the trade-off explicitly: dispatch happens after the question's transaction commits, so a crash in the window between the commit and this handler loses one small award and nothing else, no question is lost and no total is corrupted. The alternative (a second outbox contract, a broker round trip and inbox dedup for a handful of points) buys durability the feature does not need. [Rubric §29, Resilience & Business Continuity] is the same paragraph read from the operations side, and it names the exit: promoting the path later is a one-file change on each side, the aggregate raises an integration event instead and this class becomes an IIntegrationEventHandler. Contrast UserDeletedPointsHandler, which sits on the integration path and deliberately lets an exception propagate, because a missed erasure is not a missed nicety.
  • Concept introduced, taking everything off the event rather than reading it back. [Rubric §15, Best Practices & Code Quality] assesses whether a class works with the data it is actually given. The comment at :21-29 records the trap this file is written around: SessionQuestionChanged is captured by value while the aggregate is still new, so on the Added path its QuestionId is zero (the identity is generated by the INSERT, which has not run when the event is raised, and the event is never re-stamped). The event contract states the same rule at its own source (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:16-22) and carries UserId and SessionId precisely because they are set before the raise (:24-28). This handler therefore reads no row at all: the two fields it needs come off the event, so there is no read-back to get wrong.
  • Concept introduced, the subject key as the anti-farming rule. The subject key is the session, never the question (:85), so an attendee who asks five questions in one session earns once. [Rubric §8, Data Architecture] is why that holds under concurrency: the limit is enforced by the ledger's unique index inside PointsAwarder rather than by counting here (:17-19), so two simultaneous submissions cannot both slip past a read-then-write check.
  • Walkthrough
    • The primary constructor (:51-53) takes an IServiceScopeFactory and a logger. Domain event handlers are registered as singletons by the framework's convention scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:186-191), which is why this class opens its own scope instead of injecting scoped services (:45-47).
    • HandleAsync (:56) null-guards the event (:58), then returns unless the state is DomainEntityState.Added (:60-64). SessionQuestionChanged is raised for moderation and deletion too (SessionQuestion.cs:134, :158, :190, :234), so this filter is what keeps a moderator approving a question from paying the asker a second time. The skip is logged at Debug (:62, :100-101).
    • The second guard rejects a defaulted UserId (:66-73). The comment (:68-70) is worth reading as a lesson in log-level choice: the path is unreachable through the aggregate, because the Create invariants reject a default user, so reaching it can only mean a new raise site forgot to pass the asker. It is therefore a Warning (:103-104), not a silent return.
    • Inside the try (:75) one async DI scope is opened (:77) and the scoped IPointsAwarder resolved (:78). AwardAsync is called with the event's UserId, PointsActivityType.QuestionAsked, PointsSubjectKeys.ForSession(domainEvent.SessionId) and domainEvent.DateOccurred (:82-87). The comment (:80-81) explains the timestamp choice: DateOccurred is stamped when the aggregate raised the event (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:28), which is when the attendee actually asked, so no clock has to be injected here.
    • A rejected award (the awarder returning a failure) logs at Warning (:89-90, :106-107).
    • The catch (:93) swallows everything except OperationCanceledException behind an inline CA1031 suppression whose justification is written into the pragma itself (:92-94): the award is best effort and must never fail the question that was already committed. [Rubric §13, Observability & Operability] covers the discipline around it: the swallow is never silent, LogAwardFailed records the exception with the user and session (:96, :109-110), and the class comment states the rule that every declining path says so at a level matching how surprising it is (:40-44).
  • Why it's built this way: both guards exist so the game can never damage the feature it decorates. The state filter keeps the ledger honest, the broad catch keeps a points outage from becoming a Q and A outage, and taking the asker off the event removes the only lookup that could quietly award nobody. The swallow is also why this class implements IDomainEventHandler<in TDomainEvent> directly rather than deriving from the framework's SafeDomainEventHandler<TDomainEvent>: it wants the exception to stop here, not to be logged and rethrown. The points design as a whole is ADR-072.
  • Where it's used: discovered and registered as a singleton by ScanModuleApplicationServices<ClassReference>() (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88), and invoked by the framework's domain event dispatcher after the Q and A write path's SaveChangesAsync. Its raise site is SessionQuestion.Create (SessionQuestion.cs:109). Covered by SessionQuestionSubmittedPointsHandlerTests.
  • Caveats / not-in-source: how many points QuestionAsked is worth, and whether the rule is switched off at all, is configuration read inside PointsAwarder, not here.

UserSessionBookmarkCacheEvictionHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.DomainEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:43 · Level 4 · class (sealed)

  • What it is: the handler that broadcasts an output-cache eviction to the Conference service every time a bookmark is created, reactivated or removed, so Conference's cached session reads stop serving a stale bookmark count.
  • Depends on: IDomainEventHandler<in TDomainEvent> implemented over UserSessionBookmarkChanged (:46), IEventBus resolved per event (:74), OutputCacheEvictionRequested (:77) and BestEffort (:68). Externals: IServiceScopeFactory, ILogger.
  • Concept introduced, evicting a cache you do not own. [Rubric §12, Performance & Scalability] assesses whether a cross-cutting concern is solved once at the right layer. ASP.NET Core's output cache is per host: IOutputCacheStore is a local store, so a write in the owning service leaves a stale cached response sitting in front of every other process until its TTL expires (MMCA.Common/Source/Core/MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:10-13). Bookmark counts are the sharp case: they are owned by Engagement but served by Conference. The class comment (:13-21) names the symptom that motivated the class, a speaker watching their dashboard seeing a star land up to a minute later, and the previous answer, a short TTL, which is a floor rather than a fix. The fix is to make eviction an event like any other: this handler publishes OutputCacheEvictionRequested carrying one tag, and the Conference host's own eviction handler drops that tag on arrival (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:250, and the reasoning at :233-242). [Rubric §7, Microservices Readiness] is why it has to be an event at all: under database-per-service (ADR-006) and process-per-service, Engagement has no handle on the other host's cache store, so the broker is the only reachable path. [Rubric §29, Resilience & Business Continuity] covers the failure posture: the publish runs through BestEffort (:68), so a failure becomes one Warning plus one metric and the stale entry expires on Conference's own TTL exactly as it did before this existed (:31-36). The TTL stays deliberately, as the backstop for a dropped message (Program.cs:242-244).
  • Concept introduced, subscribing to the aggregate rather than to the use case. [Rubric §6, CQRS & Event-Driven] assesses where a reaction is hooked. The obvious hook is the create command handler, but the delete path runs on the framework's generic DeleteEntityCommand<TEntity, TIdentifierType> and has no ADC handler at all to add a line to. UserSessionBookmarkChanged is raised by the aggregate itself on every path that moves a count (UserSessionBookmark.cs:57 on create, :73 on reactivate, :88 on delete), so subscribing to the domain event covers all three with one class and leaves the delete flow untouched (:22-30). It also inherits the dispatch guarantee: domain-event dispatch is deferred until after the transaction commits and dropped on rollback, so no eviction is ever broadcast for a bookmark that did not persist.
  • Walkthrough
    • The primary constructor (:43-46) takes an IServiceScopeFactory and a logger; the class is a singleton by the framework convention for domain event handlers (:37-39), which is why it opens its own scope rather than injecting the scoped bus.
    • SessionsCacheTag (:53) is the literal "conference:sessions". Its doc comment (:48-52) is the load-bearing part: the tag string IS the contract between the two hosts, and it is spelled exactly as Conference registers it on its cache policies (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:223, :231, :243). Nothing in the type system checks that agreement.
    • OperationName (:56) is the low-cardinality "bookmark-cache-evict-broadcast" that becomes the operation tag on the best-effort metric.
    • HandleAsync (:59) null-guards (:63) and then hands everything to BestEffort.ExecuteAsync (:68-80). Inside the lambda it opens one async scope (:73), resolves IEventBus (:74) and publishes new OutputCacheEvictionRequested { Tags = [SessionsCacheTag] } (:76-78). Publishing through the event bus means the message is persisted to the outbox with the same machinery as any other integration event (ADR-003).
    • The handler deliberately does not filter on domainEvent.State, and the comment says why (:65-67): every state the aggregate raises (Added on create and reactivate, Deleted on removal) moves the count Conference has cached, and evicting once more than strictly needed only costs one un-cached read.
  • Why it's built this way: the alternative shapes each fail on something concrete. Hooking the command handlers misses the generic delete. Calling EvictByTagAsync locally does nothing, because the cache entries are in another process. Making the publish mandatory would let a broker hiccup fail a bookmark the attendee already saved. Subscribing to the aggregate's own event and wrapping the publish in BestEffort is the combination that covers every write path, crosses the process boundary, and cannot hurt the write it reacts to. The caching strategy this fits into is ADR-026, and the authenticated-read caching it evicts is ADR-040.
  • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88, reaching MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:186-191). Its counterpart on the receiving side is OutputCacheEvictionHandler, registered on the Conference host. Covered by UserSessionBookmarkCacheEvictionHandlerTests.
  • Caveats / not-in-source: the Conference host comment records that both halves are needed, the handler and the broker consumer, and that registering only one is a silent no-op (Program.cs:247-249); nothing in this file can detect that the other half is missing. Whether the broadcast actually reaches Conference is broker configuration and runtime, not source.

EventFeedbackService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Feedback · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/EventFeedbackService.cs:14 · Level 4 · sealed class

  • What it is: the HTTP implementation of IEventFeedbackUIService, backed by the eventquestionanswers resource (EventFeedbackService.cs:18). Its submit call is a POST-as-upsert: the same POST creates a first answer or replaces an existing one (BR-107, per the class doc at :11-12).
  • Depends on: IEventFeedbackUIService, AuthenticatedServiceBase, ITokenStorageService, HttpResultExecutor, ProblemDetailsResultReader, EventQuestionAnswerDTO, PagedCollectionResult<T>. Externals: IHttpClientFactory, System.Net.Http.Json, System.Globalization.
  • Concept: the authenticated-retried-HTTP shape is taught at BookmarkService. What this pair of feedback services adds is the upsert contract: [Rubric §9, API & Contract Design] assesses whether a client has to know which of create or update applies, and here it does not, which is what lets the feedback page loop over answered questions and post each one the same way regardless of whether the attendee is answering or revising.
  • Walkthrough
    • SubmitAnswerAsync (:21-40) posts an anonymous object { EventId, QuestionId, AnswerValue } (:31) rather than a named request record, and reads back the persisted EventQuestionAnswerDTO (:38).
    • GetMyAnswersAsync (:43-62) builds a filtered paged URL against the generic paged endpoint: filters[EventId].operator=equals&filters[EventId].value={eventId}&pageSize=100&includeChildren=false (:51). Note what it does not do: there is no user filter in the query, because the endpoint scopes to the caller server-side. The envelope is read as a PagedCollectionResult<T> and Mapped to a bare IReadOnlyList with a collection expression (:60), so the page never sees the paging wrapper.
    • DeleteAnswerAsync (:65-81) DELETEs by answer id with the owning eventId as a query parameter (:74) and returns the valueless Result.
  • Why it's built this way: all three calls use the retry overload that takes the CancellationToken (:33-36, :53-55, :75-77), so Polly's backoff waits are cancellable and a page that navigates away does not keep a doomed retry alive.
  • Where it's used: registered scoped as IEventFeedbackUIService (MMCA.ADC.Engagement.UI/DependencyInjection.cs:42); injected by EventFeedback (EventFeedback.razor.cs:22), which is the only consumer. Covered by EventFeedbackServiceTests.
  • Caveats / not-in-source: the pageSize=100 on the read is a hard ceiling in this file, so an event with more than 100 answers by one attendee would be truncated silently. No page currently approaches that, and nothing here guards it. Unlike its session-scoped twin this service has no batch submit: the server's EventQuestionAnswers controller offers no batch route (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:143 is the only POST), so the event feedback page posts answers one at a time.

PointsService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Points/PointsService.cs:16 · Level 4 · sealed class

  • What it is: the HTTP implementation of IPointsUIService against the points resource (PointsService.cs:20). Four calls, four endpoints, no client-side computation of any kind.
  • Depends on: IPointsUIService, AuthenticatedServiceBase, ITokenStorageService, HttpResultExecutor, ProblemDetailsResultReader, MyPointsDTO, LeaderboardEntryDTO, SetLeaderboardParticipationRequest, PointsOverviewDTO. Externals: IHttpClientFactory, System.Net.Http.Json, System.Globalization.
  • Concept: the authenticated-HTTP shape from BookmarkService, applied to a surface where authentication is doing four different jobs at once. The class doc (:11-14) enumerates them, and they are worth reading as a [Rubric §11, Security] exercise: the ledger read and the leaderboard opt-in are caller-scoped ("my points"), the leaderboard read is caller-agnostic but still requires a signed-in attendee (PointsController.cs:35), and the overview is role-gated on the server (PointsController.cs:121). The client enforces none of that and does not try to: it attaches the token and lets the server decide, which is why a 403 arrives as a Forbidden failure the page can render rather than as a page that guessed wrong about permissions.
  • Walkthrough (in interface order)
    • GetMyPointsAsync (:23-40) GETs points/me?pageNumber=&pageSize= with invariant formatting (:32) and returns MyPointsDTO. No user id is sent; the server reads the caller from the token (PointsController.cs:49).
    • GetLeaderboardAsync (:43-56) GETs points/leaderboard, reads a List<LeaderboardEntryDTO> (:53) and Maps it to the interface's IReadOnlyList (:54). The variance cast is the only transformation: the ordering and the ranks are the server's.
    • SetLeaderboardParticipationAsync (:59-78) PUTs a SetLeaderboardParticipationRequest carrying only the boolean (:67) to points/me/leaderboard-participation. The inline comment (:65-66) explains the shape of the round trip: the endpoint answers 204 with no body (PointsController.cs:99), so the method returns the valueless Result and the caller re-reads its own points to pick up the new opt-in state and display name.
    • GetPointsOverviewAsync (:81-97) GETs points/overview?recentCount= (:89) for the organizer rollup.
    • All four share the same three-step shape: HttpResultExecutor.ExecuteAsync on the outside, the retry policy with the cancellation-token overload in the middle, ProblemDetailsResultReader.ReadAsync on the response.
  • Why it's built this way: every number the game shows (totals, per-activity breakdowns, ranks) is computed server-side and arrives as a DTO, so there is no client-side scoring logic to keep in step with the earn rules. [Rubric §30, Compliance, Privacy & Data Governance]: the leaderboard is opt-in, so publishing a name is an explicit act with its own endpoint rather than a side effect of earning points. See ADR-072 for the surrounding decision.
  • Where it's used: registered scoped as IPointsUIService (MMCA.ADC.Engagement.UI/DependencyInjection.cs:51); consumed by MyPoints (MyPoints.razor.cs:21, called at :75, :85, :117) and OrganizerPointsOverview (OrganizerPointsOverview.razor.cs:24, called at :46). The whole controller sits behind the EngagementFeatures.Points gate (PointsController.cs:34, see EngagementFeatures and ADR-031).
  • Caveats / not-in-source: there is no PointsServiceTests file in the UI test project; coverage comes through the bUnit page tests (MyPointsTests, OrganizerPointsOverviewTests), which mock IPointsUIService. How points are earned and what each activity is worth is server-side rule, nothing in this file.

SessionFeedbackService

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Services.Feedback · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Feedback/SessionFeedbackService.cs:14 · Level 4 · sealed class

  • What it is: the HTTP implementation of ISessionFeedbackUIService, backed by the sessionquestionanswers resource (:18). The session-scoped counterpart of EventFeedbackService, same POST-as-upsert contract (BR-107, class doc :11-12), plus one call its event twin does not have.
  • Depends on: ISessionFeedbackUIService, AuthenticatedServiceBase, ITokenStorageService, HttpResultExecutor, ProblemDetailsResultReader, IdempotencyHeaders, SessionQuestionAnswerDTO, PagedCollectionResult<T>. Externals: IHttpClientFactory, System.Net.Http.Json, System.Globalization.
  • Concept introduced, the client half of request idempotency. The auth, retry and Result-reading story is taught at BookmarkService and the upsert contract at EventFeedbackService; neither is re-taught. What is new is SubmitAnswersAsync, and specifically where its idempotency key is minted. [Rubric §29, Resilience & Business Continuity] assesses whether a retry can be taken safely, and the inline comment at :54-57 is the reason it can be: the key is generated once, outside the retried delegate. A key generated inside would differ per attempt, the server could not recognize the retry, and a timed-out submit would re-apply the whole form. Because the HttpClient is created per logical operation and serves every attempt, setting the key as a default request header (:58) makes it ride along unchanged through all three retries. The generator itself is inherited: NewIdempotencyKey() is a compact GUID (AuthenticatedServiceBase.cs:43), and its remarks (:36-41) say the same thing from the framework side. The server end is ADR-017, applied to this route by the [Idempotent] attribute (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:192-193).
  • Walkthrough
    • SubmitAnswerAsync (:21-40) POSTs the anonymous { SessionId, QuestionId, AnswerValue } object (:31) to the root resource and reads back the persisted SessionQuestionAnswerDTO (:38). Same shape as its event twin.
    • SubmitAnswersAsync (:43-76) is the batch form. It null-checks the answers list (:50), mints and attaches the idempotency key (:58), projects the tuples into an anonymous { SessionId, Answers = [{ QuestionId, AnswerValue }] } body (:60-64), POSTs to sessionquestionanswers/batch (:68), and Maps the returned List<T> to the interface's IReadOnlyList (:74). The server applies the form atomically, so one Result covers the whole submit.
    • GetMyAnswersAsync (:79-98) reads the filtered paged list with filters[SessionId]...&pageSize=100&includeChildren=false (:87) and unwraps the envelope (:96).
    • DeleteAnswerAsync (:101-117) DELETEs by answer id with sessionId as a query parameter (:110).
    • All four calls use the retry overload that takes the CancellationToken (:33, :66, :89, :111).
  • Why it's built this way: a session feedback form is several answers a person filled in at once, and half-applying it on a flaky conference wifi connection would be worse than failing outright. The batch endpoint plus the replayed idempotency key is what turns "submit the form" into a single atomic, safely retryable operation.
  • Where it's used: registered scoped as ISessionFeedbackUIService (MMCA.ADC.Engagement.UI/DependencyInjection.cs:43); injected by SessionFeedback (SessionFeedback.razor.cs:21), which reads existing answers (:122), submits the whole pending form through the batch call (:226) and deletes single answers (:259). [Rubric §14, Testability]: SessionFeedbackServiceTests covers eleven facts, including that the batch call sends the whole form in one request (:61) and that it carries an Idempotency-Key header (:91), that a domain-exception response becomes a failure carrying the server's own message (:43, :109, :191), that an empty envelope reads as an empty list (:162), and that an already-cancelled token aborts before a request is sent (:209).
  • Caveats / not-in-source: SubmitAnswerAsync (the single-answer form) sends no idempotency key, even though the server route carries [Idempotent] (SessionQuestionAnswersController.cs:165-166). A retried single-answer submit is therefore deduped by the upsert semantics rather than by replay. The pageSize=100 on the read is the same hard ceiling as on the event side.

CheckInsController

MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:39 · Level 5 · class (sealed)

  • What it is: the REST surface for QR badge check-in. Six endpoints covering the attendee's own badge, the organizer scan and its manual fallback, the two attendee self-recorded scan surfaces (sponsor booth, room), and the organizer attendance rollup.
  • Depends on: ApiControllerBase (for HandleFailure), six injected handlers over ICommandHandler<in TCommand, TResult> and IQueryHandler<in TQuery, TResult> (CheckInsController.cs:40-45), the request and result contracts CheckInAttendeeRequest, ManualCheckInRequest, SponsorVisitRequest, RoomCheckInRequest, CheckInResultDTO, SponsorVisitResultDTO, RoomCheckInResultDTO, MyBadgeDTO, AttendanceStatsDTO, the use-case types GetOrCreateMyBadgeCommand and GetAttendanceStatsQuery, plus IdempotentAttribute, EngagementFeatures, EngagementPermissions and Result. Externals: ASP.NET Core MVC, Asp.Versioning, and Microsoft.FeatureManagement.Mvc's [FeatureGate].
  • Concept introduced, the authorization ladder on one controller. [Rubric §11, Security] assesses whether each endpoint carries the weakest authorization that is still correct. This controller has three rungs, and reading them top to bottom is the fastest way to understand the whole check-in feature. The class-level [Authorize] (:34) is the floor: authenticated, no policy, no role. Three endpoints add [HasPermission(EngagementPermissions.CheckInManage)] (:75, :99, :179), which is the organizer rung. The remaining three deliberately stay at the floor, and the doc comments say why: my-badge (:43-45) and the two self-recorded scans (:117-119, :148-149) take the attendee from the token and never from the request, so there is no ownership argument a caller could tamper with and therefore no ownership check to get wrong. [Rubric §9, API & Contract Design] covers the other decision worth studying: a repeat scan answers 200 with an AlreadyCheckedIn flag rather than 409 (:62-67, :151-152), because at a door a second scan is a normal event, not a client error, and the organizer needs to see whose badge it is either way. [Rubric §12, Performance & Scalability] covers feature gating: the whole controller is behind EngagementFeatures.CheckIn (:33) and the two self-service endpoints add their own gates (:130, :161), so a disabled surface answers 404 rather than 403 (ADR-031).
  • Concept introduced, idempotency layered onto an already-repeatable endpoint. Every one of the four POSTs carries [Idempotent] (:74, :98, :129, :160), the framework's replay cache (ADR-017, IdempotentAttribute). The doc comments are careful about why that is safe rather than merely convenient, and they are worth reading as a checklist: the endpoint's response must not drift within the retry window. For the scan and manual paths the argument is that a repeat already answers the same 200 (:68-72, :94-96), so a replayed response says exactly what a re-executed one would; for sponsor visits it is AlreadyVisited (:124-127); for room check-in the comment adds the load-bearing extra clause, that the session is resolved server-side (:154-158), so nothing in the response depends on a value that could change mid-retry. [Rubric §29, Resilience & Business Continuity] is what this buys: a conference-day network that is dropping responses stops costing a second database round trip per retry.
  • Walkthrough (endpoints in file order)
    • GetMyBadgeAsync (:49), GET my-badge: dispatches a parameterless GetOrCreateMyBadgeCommand (:53), which mints a badge on first use. It is a command rather than a query precisely because it can write.
    • CheckInAsync (:80), POST: the organizer scan path, dispatching CheckInAttendeeRequest to CheckInAttendeeHandler. Declares 400, 403 and 404 alongside the 200 (:76-79).
    • ManualCheckInAsync (:104), POST manual: the fallback for a dead phone or a head with no camera (:91-93), same permission and same outcome shape.
    • RecordSponsorVisitAsync (:134), POST sponsor-visits: attendee self-service behind EngagementFeatures.SponsorVisits (:130). The response carries the sponsor name so the landing page needs one round trip (:121-122).
    • RecordRoomCheckInAsync (:165), POST room-visits: attendee self-service behind EngagementFeatures.RoomCheckIn (:161). The session is never client supplied; the server resolves it from the room plus the configured grace window and answers 404 CheckIns.NoCurrentSession when nothing is running there (:149-151).
    • GetAttendanceStatsAsync (:182), GET stats?eventId=: the organizer rollup, with the event id [FromQuery, Required] (:183) and no paging arguments.
    • Every action is the same three lines: dispatch the handler, then HandleFailure(result.Errors) or Ok(result.Value). No branching, no mapping, no business rule lives in this file.
  • Why it's built this way: the class comment (:24-28) records the scoping fact that explains the whole surface: the conference runs door and arrival check-in through TicketLeap, so the Event scope here is not a door process. Session check-in is the working path, and Event scope stays available for a future info-desk or points-activation use. Keeping every endpoint a pure dispatch keeps the decisions (idempotency of the business rule, session resolution, badge verification) in handlers that are unit testable without HTTP (ADR-072).
  • Where it's used: mounted at /checkins by the route attribute (:31) and fronted by the YARP Gateway. Its clients are the organizer scanning UI and the two attendee landing pages, RoomCheckIn and SponsorVisit, through ICheckInUIService. Covered by CheckInsControllerTests.
  • Caveats / not-in-source: which features are switched on in a given environment is configuration. The permission-to-role mapping behind EngagementPermissions.CheckInManage is resolved by the auth layer, not here, and the idempotency key header and retention window are the framework filter's, not this controller's.

PointsController

MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:36 · Level 5 · class (sealed)

  • What it is: the REST surface for the points game: the caller's own ledger, the public leaderboard, joining or leaving that leaderboard, and the organizer rollup.
  • Depends on: ApiControllerBase, four handlers over ICommandHandler<in TCommand, TResult> / IQueryHandler<in TQuery, TResult> (PointsController.cs:37-40), the queries GetMyPointsQuery, GetLeaderboardQuery and GetPointsOverviewQuery, the request SetLeaderboardParticipationRequest, the contracts MyPointsDTO, LeaderboardEntryDTO and PointsOverviewDTO, plus EngagementFeatures, EngagementPermissions and Result. Externals: ASP.NET Core MVC, Asp.Versioning, [FeatureGate], and [Range] from System.ComponentModel.DataAnnotations.
  • Concept introduced, the surface with no ownership argument. [Rubric §11, Security] assesses how ownership is enforced per endpoint. Contrast this controller with BookmarksController, which has to bind a body field and a query argument to the caller's claim in two different ways. Here the class comment (:24-29) states the design instead: nothing on this surface takes a user id. The three attendee endpoints resolve the caller from the token inside their handlers, so there is no argument a caller could change, and the one endpoint that reads across every attendee returns no attendee identity at all. [Rubric §30, Compliance, Privacy & Data Governance] is the other half: the leaderboard serves only the display-name snapshot an attendee published at opt-in (:69-74), so rendering the board makes no call into Identity and exposes nothing an attendee did not choose to publish, and the organizer overview carries activity, points and timestamps only (:116-119). [Rubric §9, API & Contract Design] covers the paging arguments: pageNumber/pageSize (:54-55) and recentCount (:126) are [Range]-validated and select how much comes back, never whose data it is (:44-48).
  • Walkthrough (endpoints in file order)
    • GetMyPointsAsync (:53), GET me: dispatches GetMyPointsQuery with the paging pair defaulting to page 1 of 20 (:54-55, :59), returning the running total, the caller's leaderboard status and a page of ledger entries.
    • GetLeaderboardAsync (:78), GET leaderboard: dispatches a parameterless GetLeaderboardQuery (:82). The board length is fixed by configuration (Points:LeaderboardSize), not by the caller (:72-73).
    • SetLeaderboardParticipationAsync (:103), PUT me/leaderboard-participation: the one write. The request carries only a boolean because the published display name is taken from the caller's token server-side and never from the body (:93-97), and it answers 204 (:100, :111).
    • GetPointsOverviewAsync (:125), GET overview: the only endpoint with a permission, [HasPermission(EngagementPermissions.PointsViewOverview)] (:121).
    • The whole controller is behind [FeatureGate(EngagementFeatures.Points)] (:34) with a bare [Authorize] (:35) as the floor.
  • Why it's built this way: designing the routes so no endpoint accepts an identity is a cheaper guarantee than checking one. There is no filter to forget and no claim comparison to invert, which is the failure mode the inline checks on BookmarksController exist to prevent. See ADR-072 for the points design and ADR-033 for the ownership axis this surface sidesteps.
  • Where it's used: mounted at /points (:32) behind the Gateway; consumed by MyPoints and OrganizerPointsOverview through IPointsUIService. Covered by PointsControllerTests.
  • Caveats / not-in-source: Points:LeaderboardSize and the per-activity point values are configuration, read by the handlers rather than by this controller. Note also that the one write here carries no [Idempotent], unlike the four POSTs on CheckInsController: setting participation is a PUT of an absolute value, so a replay lands on the same state without help.

UserDeletedPointsHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:37 · Level 8 · class (sealed partial)

  • What it is: the erasure handler for the leaderboard. When Identity deletes an account, this takes the entry off the public board and overwrites the display name it published there. It is the one consumer in this folder that awards nothing.
  • Depends on: ScopedIntegrationEventHandlerBase<TIntegrationEvent> closed over Identity's UserDeleted (:40), IUnitOfWork (:50), the IRepository<TEntity, TIdentifierType> it obtains for LeaderboardOptIn (:51) and that aggregate's own Delete() / EraseDisplayName() members (:69, :81). Externals: IServiceScopeFactory, source-generated [LoggerMessage] (:96-103).
  • Concept introduced, cross-database erasure by event. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether a deletion promise actually reaches every copy of the data. Under database-per-service (ADR-006) Identity cannot delete a row in the Engagement database, so an erasure has to travel as an event and be carried out by the owning service. The class comment (:13-21) names the exact scope: the leaderboard is the one place Engagement holds a name rather than a scalar id, LeaderboardOptIn.DisplayName is a snapshot the attendee published at opt-in, and without this handler that name would keep sitting in the table after the account was erased. It also names what is deliberately left alone: the points ledger, because a PointsEntry carries a scalar user id and an opaque subject key and names nobody, the same posture already applied to session bookmarks. That is the ADR-005 distinction between soft-delete for lifecycle and erasure for personal data, applied per column rather than per table. [Rubric §29, Resilience & Business Continuity] covers the retry posture stated at :26-27: exceptions are deliberately not caught, so a failure is logged once by the base and then propagates, and the outbox and the broker retry it rather than acking it away with a log line (MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:26-33). The award adapters share that exception posture, because they share the base; what differs is what they do with a rejected award, which they log and move past, whereas this handler has no such branch: a missed point is a nuisance, a missed erasure is a broken promise.
  • Walkthrough
    • HandleScopedAsync (:43-46) is the override; the base has null-guarded the event and opened the per-delivery scope. The body takes the user id (:48), then reaches the scoped IUnitOfWork (:50) and a tracked repository for LeaderboardOptIn (:51) out of the supplied provider.
    • The read (:56-61) is the subtle line. It passes asTracking: true (mutations follow) and ignoreQueryFilters: true, and the comment (:53-55) explains that the flag is load-bearing here for the opposite reason it is used on the join path: an attendee who left the board earlier still has a soft-deleted row carrying the published name, and a filtered read would walk straight past the very row that has to be erased.
    • The loop (:65-84) does two independent things per row. If the row is not already deleted it calls Delete() and, on failure, logs and moves to the next row rather than aborting the batch (:67-77). If the display name is not already erased it calls EraseDisplayName() (:79-83). Either action sets changed.
    • When nothing changed the handler logs and returns without a save (:86-90); otherwise it saves once for the whole batch and logs the row count (:92-93).
  • Why it's built this way: the two-flag loop is what makes the handler idempotent in both directions (:22-25): an account that never joined the board has no row and writes nothing, and an account already off the board with an erased name reaches the same end state and writes nothing again. At-least-once delivery makes redelivery normal, so that property is a requirement rather than a nicety.
  • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:193-198), with the consumer wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:289; the surrounding comment (:264-266) records that this is the one consumer here that earns nothing. Covered by UserDeletedPointsHandlerTests.
  • Caveats / not-in-source: the privacy commitment the comment cites (PRIVACY.md section 5) lives in the ADC repo's private docs, not in this file. What EraseDisplayName() writes in place of the name is defined on LeaderboardOptIn.

AttendeeCheckedInPointsHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:31 · Level 10 · class (sealed partial)

  • What it is: the award adapter for check-ins. It turns an AttendeeCheckedIn into the (activity, subject key) pair the ledger understands and hands it to IPointsAwarder. It is the richest of the award adapters because the incoming scope is a string that has to be mapped.
  • Depends on: ScopedIntegrationEventHandlerBase<TIntegrationEvent> closed over AttendeeCheckedIn (:34), IPointsAwarder (:48), CheckInScopeNames (:70, :77, :88), PointsActivityType and PointsSubjectKeys (:72-73, :80-81, :91-92). Externals: IServiceScopeFactory, StringComparison.Ordinal, source-generated [LoggerMessage] (:101-105).
  • Concept introduced, treating an unknown contract value as data, not as a fault. [Rubric §6, CQRS & Event-Driven] assesses how a consumer behaves when the producer is ahead of it. The class comment (:16-22) states the rule: the scope arrives as a wire string rather than an enum, so a value this build has never heard of is normal contract evolution, and the handler logs a warning and awards nothing instead of throwing and dead-lettering a message that no retry could ever fix. [Rubric §29, Resilience & Business Continuity] is the practical consequence: an unmappable payload cannot wedge the queue. [Rubric §3, Clean Architecture] is the same boundary its siblings keep, stated in this file's own words (:12-15): all the ADC vocabulary lives here and the awarder below it knows nothing about events or sessions.
  • Walkthrough
    • HandleScopedAsync (:37-40) is the override. The base has already null-guarded the event and opened the delivery scope, so the first thing the body does is call TryMapAward (:42), before resolving anything: an unmappable event costs no service resolution and no database work, only the warning at :44.
    • On a successful map it resolves the scoped IPointsAwarder from the supplied provider (:48) and awards with the event's CheckedInOn.UtcDateTime (:50-55). A rejected award is logged at Warning with the activity, the user and the subject key (:57-58), not rethrown.
    • TryMapAward (:65) is three ordinal string comparisons in scope order. Event maps to PointsActivityType.EventCheckIn scoped by PointsSubjectKeys.ForEvent (:70-75). Session maps to SessionCheckIn only when the payload actually carries a session id, using a property pattern (:77-83). Sponsor maps to SponsorVisit under the same condition on the sponsor id (:88-94), and the comment above it (:85-87) explains why a Sponsor scope with no sponsor id falls through to the same log-and-skip as an unknown scope: an award scoped to nothing would collide with every other sponsor-less payload on the ledger's uniqueness rule.
    • The fall-through sets activity = default, an empty subject key, and returns false (:96-98), which is the single exit the caller's guard reads.
  • Why it's built this way: this handler is where the module's most interesting delivery property lives. The event is published by the same service that consumes it, so the check-in write and the award are two separate transactions joined by the broker (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:269-275). That keeps the scan endpoint's latency independent of the points write and lets the award retry on its own. The same comment records the fallback if a deployment ever has trouble with the round trip (:275-277): the award could move to an in-module domain event handler on the same CheckIn creation, which is a one-file change because the module already awards session-question points that way, through SessionQuestionSubmittedPointsHandler.
  • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:193-198), which is why the base opens a scope per delivery (:23-27); the consumer is wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:286. Covered by AttendeeCheckedInPointsHandlerTests.
  • Caveats / not-in-source: how many points each activity is worth, and whether a rule is switched off, is decided inside PointsAwarder from configuration, not here.

BookmarksController

MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:34 · Level 11 · class (sealed)

  • What it is: the REST surface for session bookmarks, the attendee's personal schedule (UC-11): create one, list a user's bookmarks paginated, fetch the bookmarked session ids as a lookup, and delete one. All endpoints require authentication (BR-42, :25-26).
  • Depends on: ApiControllerBase, a create handler over CreateBookmarkRequest (:34), query handlers for GetUserBookmarksQuery (:35) and GetBookmarkedSessionIdsQuery (:36), a delete handler over DeleteEntityCommand<TEntity, TIdentifierType> (:37), IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> for the ownership probe (:38), ICurrentUserService (:39), plus UserSessionBookmark, UserSessionBookmarkDTO, PagedCollectionResult<T>, Error, RoleNames, OwnerOrAdminFilter, IdempotentAttribute and EngagementFeatures. Externals: ASP.NET Core MVC, Asp.Versioning, [FeatureGate], CultureInfo for the Created location.
  • Concept introduced, three ways to enforce one ownership rule. [Rubric §11, Security] assesses row-level authorization, and this controller is the reference example in the module because the same rule ("a non-organizer may only touch their own bookmarks") is enforced three different ways, each matched to where the owner identity actually travels (ADR-033).
    1. On the two GETs the owner arrives as a userId query argument, so the shared [ServiceFilter(typeof(OwnerOrAdminFilter))] (:84, :105) compares it to the caller's user_id claim, with Organizer bypassing. The ADC vocabulary for that filter is configured in the Engagement module registration, not here (:80-82).
    2. On POST the owner arrives in the request body, which no query-argument filter can see, so the check is inline (:58-70) and the comment records the consequence of omitting it (:58-62): any authenticated user could create bookmarks owned by another user by supplying a foreign UserId.
    3. On DELETE the owner is not in the request at all, it is a property of the stored row, so the check is a database probe (:139-141). The DELETE also teaches a second decision: it returns 404 rather than 403 for a bookmark the caller does not own (:129-130, :144-147), so the response cannot be used to learn that a given bookmark id exists. Contrast the whole picture with PointsController, which avoids all three mechanisms by never accepting a user id.
  • Walkthrough (endpoints in file order)
    • The class carries [FeatureGate(EngagementFeatures.SessionBookmarks)] (:31) and a bare [Authorize] (:32), and is mounted at /bookmarks (:29).
    • CreateAsync (:54): the one endpoint with [Idempotent] (:50). The doc comment states the safety argument (:44-48): the request names the (user, session) pair explicitly and a duplicate already answers 409, so replaying a retried star is what the attendee meant rather than a second row or a spurious conflict (ADR-017). The action then runs the inline body-vs-claim check for non-organizers, failing with Error.Forbidden("Bookmarks.Forbidden", ...) when the claim is missing or does not match (:63-70), dispatches, and answers 201 with a relative location built culture-invariantly (:76).
    • GetUserBookmarksAsync (:86): userId is [FromQuery, Required], eventId is an optional filter, and paging defaults to page 1 of 10 (:87-90); returns a PagedCollectionResult<T>.
    • GetBookmarkedSessionIdsAsync (:107): returns an IReadOnlyDictionary<SessionIdentifierType, UserSessionBookmarkIdentifierType> (:106-107), which is the shape a session list needs to render bookmark toggles without one call per row.
    • DeleteAsync (:125): for a non-organizer it resolves the caller id, forbids when absent (:133-137), then asks the query service whether a row with this id belongs to this user (:139-141) and, if not, returns a NotFound error tagged with the controller and entity names (:144-147). Only then does it dispatch the generic DeleteEntityCommand<TEntity, TIdentifierType> (:150-152), which soft-deletes, and answer 204 (:156).
  • Why it's built this way: the mixed enforcement is deliberate rather than inconsistent. A shared action filter can only read what is on the request line, so the body and the stored-row cases cannot use it, and pushing the DELETE check into the handler would either duplicate it across every future mutation or force the generic delete command to learn about ownership. Keeping the probe inline is the same per-mutation pattern Store's OrdersController uses.
  • Where it's used: mounted at /bookmarks and routed to the Engagement service by the Gateway; consumed by the Conference session-list and personal-schedule surfaces. Note that the writes here are also what trigger UserSessionBookmarkCacheEvictionHandler: the aggregate raises UserSessionBookmarkChanged on every path this controller reaches, so a star or an un-star broadcasts an output-cache eviction to Conference. Covered by BookmarksControllerTests.
  • Caveats / not-in-source: the OwnerOrAdminFilter configuration (the user_id claim name, the userId argument name, and the Organizer bypass role) is set during module registration and is not visible in this file. The business-rule numbers in the comments (UC-11, BR-42) are the controller's own claim; the authoritative statements live in the ADC specifications guide.

MyPoints

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/MyPoints.razor.cs:16 · Level 5 · class (partial)

  • What it is: the code-behind for the attendee's own points page (@page "/points", MyPoints.razor:1, @attribute [Authorize], MyPoints.razor:2). It shows the running total, the ledger entries behind it, the leaderboard opt-in switch, and the public board.
  • Depends on: IPointsUIService (the only injected service, MyPoints.razor.cs:21), Result (the load method's own return type, :73), PointsEntryDTO (:35), LeaderboardEntryDTO (:36), MyPointsDTO (read at :75-83) and PointsActivityType (:158). Externals: Blazor ComponentBase, MudBlazor BreadcrumbItem / Icons / MudSwitch, System.Globalization, and an injected IStringLocalizer<MyPoints> named L from the markup (MyPoints.razor:6).
  • Concept introduced, making a stateful child control follow the server. [Rubric §19, State Management] assesses who owns a visible piece of state. A MudSwitch keeps its own checked state, and Blazor re-parameterizes a child only when a parameter actually changes, so after a rejected write the control would keep showing what the attendee clicked while the page holds what the server accepted. The page solves it with a render key: _switchGeneration (:42) is bound as @key on the switch (MyPoints.razor:72) and incremented whenever the server's value disagrees with the requested one (:145-148), which re-mounts the control and forces it to drop its internal state. [Rubric §24, Forms, Validation & UX Safety] covers the second decision in the same method: a failed write and a failed read-back are reported differently (:111-114), because conflating them made a refresh blip force the switch back to the pre-write value and show the opposite of the server state. [Rubric §27, Internationalization] covers both the L["..."] resource lookups and the culture-aware timestamp rendering (ADR-027).
  • Concept introduced, a page that reads failures instead of catching them. [Rubric §1, SOLID] and [Rubric §15, Best Practices & Code Quality] both show in one detail: LoadAsync returns Result (:73) rather than throwing, and it composes the two service calls by propagating their errors (:76-79, :86-89). The only exception the page catches is OperationCanceledException on disposal (:59-62, :131-134), so the error state is driven by a value the UI service returned, not by an exception type the page has to recognize.
  • Walkthrough (teaching order)
    • EntryPageSize (:19) fixes the activity feed at 20 entries per read; the injected IPointsUIService (:21), a CancellationTokenSource (:23) and the breadcrumb list (:25) follow. IsLoading is protected with a private setter (:28) so component tests can observe the first load.
    • The private state (:30-36) is a load error, a save error, an in-flight save latch, the total, the opt-in flag, the entries and the board. _switchGeneration (:42) is the render key described above.
    • OnInitializedAsync (:44) builds the two-item breadcrumb trail (:46-50), then awaits LoadAsync and sets the localized load error when it comes back a failure (:54-57), swallows OperationCanceledException as expected on disposal (:59-62), and clears IsLoading in finally (:63-66).
    • LoadAsync (:73) makes two calls: the caller's ledger (:75) and the public board (:85). Each is unwrapped with TryGetValue and short-circuits into Result.Failure carrying the service's own errors (:76-79, :86-89). Both are re-read after an opt-in change, because joining adds a row to the board and leaving removes one (:69-72).
    • OnParticipationChangedAsync (:101) returns early when a save is already in flight (:103-106), latches _isSaving and clears the previous save error (:108-109), then writes through SetLeaderboardParticipationAsync (:117). A failed write sets the save-failed message (:118-121); a successful write followed by a failed refresh sets the refresh-failed message instead and adopts the requested value locally (:122-129) so the key guard below does not re-mount the switch back onto a stale reading.
    • ActivityLabel (:158-168) maps each PointsActivityType member to a resource key by an explicit switch rather than by enum name, so a renamed member breaks the build instead of silently rendering a missing key (:151-155).
    • FormatOccurredOn (:177-180) stamps DateTimeKind.Utc before converting to local time. The comment (:170-176) gives the reason: a serializer that drops the UTC marker leaves the value Unspecified, and the local conversion would then shift an already-local reading a second time.
    • Dispose(bool) / Dispose() (:184-204) are the standard disposable pattern with a _disposed latch, cancelling and disposing _cts so an in-flight load stops when the attendee navigates away.
  • Why it's built this way: the page makes one promise, that the switch shows the server's state and not the click's, and both the key bump and the split error messages exist to keep it. Everything else is orchestration only: all I/O goes through IPointsUIService, which keeps the page testable under bUnit and swappable per host.
  • Where it's used: routed at /points, the value of EngagementRoutePaths.MyPoints (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:26). Component-tested by MyPointsTests.
  • Caveats / not-in-source: the tables, the empty states and every visible string live in MyPoints.razor and the resource files. Whether the page is reachable at all depends on the Engagement.Points feature gate on PointsController, which is configuration.

OrganizerPointsOverview

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Pages.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/OrganizerPointsOverview.razor.cs:19 · Level 5 · class (partial)

  • What it is: the code-behind for the organizer rollup of the points game (@page "/organizer/points", OrganizerPointsOverview.razor:1, @attribute [Authorize(Roles = "Organizer")], OrganizerPointsOverview.razor:2): how many attendees are playing, what the economy has paid out, which mechanic the room actually used, and the newest awards.
  • Depends on: IPointsUIService (OrganizerPointsOverview.razor.cs:24), PointsOverviewDTO (:34) and PointsActivityType (:76). Externals as on MyPoints: MudBlazor BreadcrumbItem / Icons, System.Globalization, and an injected IStringLocalizer<OrganizerPointsOverview> (OrganizerPointsOverview.razor:6).
  • Concept introduced, distinguishing "no data yet" from "the read failed". There is no write on this surface, so none of the switch-state machinery MyPoints needs appears here, but the load does one thing its sibling does not. [Rubric §24, Forms, Validation & UX Safety] assesses whether the UI tells the truth about what happened: the load unwraps the Result with TryGetValue and, when it is a failure, checks IsNotFound() before deciding (:47-57). A not-found rollup leaves _overview null so the page keeps rendering its empty state, and only a real failure becomes the error state; the comment (:53-55) records that this preserves the behavior the older null-returning service produced. [Rubric §30, Compliance, Privacy & Data Governance] is the other point worth teaching: the class comment (:13-17) states that the recent entries carry NO attendee identity by design, because the overview endpoint reports activity, points and timestamps only. An organizer can watch the game move without seeing who earned what, and the only published identities anywhere in this feature are the leaderboard names attendees opted in to. [Rubric §11, Security] covers the role gate, which is declared on the markup rather than in this file.
  • Walkthrough: RecentEntryCount (:22) fixes the tail at 20 entries. IsLoading (:31) is protected with a private setter for the component tests; _loadError and _overview (:33-34) are the only other state. OnInitializedAsync (:36) builds the breadcrumbs (:38-42), awaits GetPointsOverviewAsync(RecentEntryCount, _cts.Token) (:46), applies the found / not-found / failed split described above (:47-57), swallows OperationCanceledException (:59-62) and clears IsLoading in finally (:63-66). ActivityLabel (:76-86) and FormatOccurredOn (:95-98) are the MyPoints helpers repeated verbatim, including the explicit DateTimeKind.Utc stamp and its recorded reason (:88-94). Dispose(bool) / Dispose() (:102-122) are the same disposable pattern over _cts.
  • Why it's built this way: the two helper methods are duplicated across the two points pages rather than shared, the same trade-off the feedback pages make with their nested answer holder: each page stays self-contained and independently editable, and the duplicated code is two small pure functions with no state.
  • Where it's used: routed at /organizer/points, the value of EngagementRoutePaths.OrganizerPointsOverview (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:27). Its data comes from PointsController's overview endpoint (PointsController.cs:125) via GetPointsOverviewHandler. Component-tested by OrganizerPointsOverviewTests.
  • Caveats / not-in-source: the stat cards, the per-activity table and the recent-entry table live in OrganizerPointsOverview.razor, not in this code-behind.

CheckInInvariants

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckInInvariants.cs:10 · Level 6 · class (static)

  • What it is: the five invariant rules for CheckIn. Four are simple id and enum checks; the fifth encodes the rule that makes one aggregate able to carry three different check-in shapes.
  • Depends on: CommonInvariants (CheckInInvariants.cs:2), Result and Error (Error.Invariant at :88, :93, :104), plus CheckInScope (:45) and the UserIdentifierType / EventIdentifierType / SessionIdentifierType / SponsorIdentifierType aliases (solution-wide global using, see primer §2). Externals: System.Enum.
  • Concept introduced, the discriminated-shape invariant. [Rubric §4, Domain-Driven Design] assesses whether the model can express an illegal state. This aggregate is a polymorphic row: a Session check-in must name a session and must not name a sponsor, a Sponsor visit is the mirror image, and an Event check-in names neither. EnsureTargetMatchesScope states that rule once, in the domain, so no handler and no controller can persist a row that belongs to two shapes at once (:33-37). [Rubric §8, Data Architecture] is the reason it matters beyond tidiness: the storage layer builds three filtered unique indexes on the same table, one per scope (CheckInConfiguration, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:48-62), and a row with two targets set would be visible to two of them. The invariant and the index filters are two statements of one rule, which the doc comment names explicitly (:34-36).
  • Walkthrough (teaching order)
    • EnsureUserIdIsValid (:16-17), EnsureEventIdIsValid (:23-24) and EnsureCheckedInByUserIdIsValid (:30-31) are three CommonInvariants.EnsureIdIsNotDefault delegations with stable codes ("CheckIn.UserId.Invalid", "CheckIn.EventId.Invalid", "CheckIn.CheckedInByUserId.Invalid"). The event id is required for every scope (:19), which is what lets the attendance rollup bucket any row by conference.
    • EnsureTargetMatchesScope (:44-71) runs the session check first and returns it when it fails (:50-61), otherwise runs the sponsor check (:62-70), so the caller gets the first specific failure rather than a merged pair.
    • EnsureTargetPresence (:75-95) is the private rule stated once. isOwningScope decides the direction: the owning scope requires the id and treats null or default as missing (:87-89), and every other scope forbids it outright (:92-94). The comment above it (:73-74) records why one method serves both targets: both are int aliases, so the parameter is typed int?.
    • EnsureScopeIsDefined (:101-104) rejects an undefined enum value with Enum.IsDefined, which matters because a scope can arrive from a deserialized request rather than from C# code.
  • Why it's built this way: pushing the shape rule into the domain rather than into each use case means the three write paths (organizer scan, sponsor visit, room check-in) cannot drift apart, and the separate error codes make a failure legible at the API boundary without a message-parse.
  • Where it's used: composed with Result.Combine inside CheckIn.Create (CheckIn.cs:98-103); that factory is the only caller.
  • Caveats / not-in-source: EnsureTargetPresence treats a supplied-but-default id as missing only for the owning scope (:87); a non-owning scope rejects any non-null value, default included (:92).

LeaderboardOptIn

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:19 · Level 6 · class (sealed)

  • What it is: the aggregate root recording that one attendee agreed to publish their score on the points leaderboard. Opting out soft-deletes the row, so "the board" is exactly the set of active opt-ins and nothing else (LeaderboardOptIn.cs:9-11). It carries two pieces of state: the attendee's id and the display name they chose to publish.
  • Depends on: AuditableAggregateRootEntity<TIdentifierType> (bound to LeaderboardOptInIdentifierType), LeaderboardOptInInvariants, the LeaderboardOptInChanged domain event, DomainEntityState, IdValueGeneratedAttribute, and Result. Externals: only System.StringComparison.
  • Concept introduced, the published-name snapshot. [Rubric §7, Microservices Readiness] assesses whether a read can be served without reaching across a service boundary. DisplayName is not a lookup key into Identity: it is the name the attendee explicitly published at opt-in, stored here verbatim (LeaderboardOptIn.cs:12-16). That single choice is what lets GetLeaderboardHandler project the whole board out of the Engagement database (GetLeaderboardHandler.cs:39-43) with no gRPC call into Identity, and it also bounds the exposure: the board can only ever leak the name the attendee chose to put on it. [Rubric §30, Compliance, Privacy and Data Governance] is the other half, taught below on EraseDisplayName.
  • Concept, opt-in as a row rather than a flag. [Rubric §4, Domain-Driven Design] assesses whether the model states the rule rather than encoding it in a boolean somewhere. Participation is a first-class aggregate with its own lifecycle (join, leave, rejoin), which means leaving is auditable (CreatedOn/By and LastModifiedOn/By come from the auditable base) and rejoining is a state transition on the same row rather than a new record.
  • Walkthrough
    • [IdValueGenerated] (LeaderboardOptIn.cs:18): the id is database-generated, so the factory writes Id = default and SQL Server's IDENTITY fills it (see IdValueGeneratedAttribute).
    • DisplayNameMaxLength = 100 (:22) and ErasedDisplayName = "Deleted Account" (:29): two constants that the rest of the system reads rather than restates. The EF configuration binds the column length to the first (LeaderboardOptInConfiguration, LeaderboardOptInConfiguration.cs:28-30) and LeaderboardOptInInvariants validates against it (LeaderboardOptInInvariants.cs:23). The second matches the placeholder the Identity aggregate anonymizes to, so an erased account reads the same wherever a row survives (:24-28).
    • UserId (:32) and DisplayName (:35): private set scalars. UserId is a cross-database reference, never a navigation (ADR-006).
    • IsDisplayNameErased (:42): a computed bool comparing DisplayName to ErasedDisplayName with StringComparison.Ordinal. It exists so a redelivered erasure can recognise that there is nothing left to write (:37-41).
    • Two constructors, an EF-only parameterless one (:45) and a private assigning one (:47-51). Construction always runs through the factory.
    • Create(userId, displayName) (:60-78): Result.Combine over both invariants (:64-66), errors re-wrapped as Result.Failure<LeaderboardOptIn> on failure (:67-68), the entity built with Id = default (:70-73), then LeaderboardOptInChanged(DomainEntityState.Added, ...) raised (:75).
    • Reactivate(displayName) (:87-102): validates the name first (:89-91), calls the inherited Undelete() (:93), and only on success overwrites DisplayName and raises the same Added event (:97-98). Rejoining therefore republishes the attendee's current name, not the one they had the first time (:80-83, BR-135).
    • Delete() (:109-117): overrides the base soft-delete, calls base.Delete() (:111) and raises LeaderboardOptInChanged(DomainEntityState.Deleted, ...) on success (:114). This is "left the board", not "erased".
    • EraseDisplayName() (:130): a one-line, irreversible overwrite of DisplayName with ErasedDisplayName. [Rubric §30, Compliance, Privacy and Data Governance] assesses whether erasure is modelled distinctly from deletion. The remarks (:119-129) state why the two are deliberately separate methods: taking an entry off the board and erasing the name it carried are different promises, and only the second is irreversible. The row itself survives (anonymize in place, ADR-005), so the scalar UserId reference and the audit trail stay intact, and the operation is idempotent because a redelivered erasure rewrites the same placeholder.
  • Why it's built this way: the unique index on UserId is filtered on the soft-delete flag (LeaderboardOptInConfiguration, LeaderboardOptInConfiguration.cs:33-35), so an attendee can hold at most one active opt-in while their history survives (ADR-095). That index is precisely what makes Reactivate necessary rather than optional: without it a rejoin would insert a second row and collide, which is the reasoning recorded at the call site (SetLeaderboardParticipationHandler.cs:67-69).
  • Where it's used: reactivated and created by SetLeaderboardParticipationHandler (SetLeaderboardParticipationHandler.cs:87 and :92 respectively); read by GetLeaderboardHandler (GetLeaderboardHandler.cs:39-43, ordering tie-break on DisplayName at :71); soft-deleted and erased by UserDeletedPointsHandler (UserDeletedPointsHandler.cs:63-82); persisted per LeaderboardOptInConfiguration. Unit-tested by LeaderboardOptInTests.
  • Caveats / not-in-source: the erasure comment cites PRIVACY.md section 5 (LeaderboardOptIn.cs:121); that document lives in the ADC repo's own docs, not in this file, so the citation cannot be verified from the source under this type.

LeaderboardOptInInvariants

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptInInvariants.cs:9 · Level 6 · class (static)

  • What it is: the two-rule invariant helper for LeaderboardOptIn: the opt-in must name a real attendee, and the published name must be present and fit the column.
  • Depends on: CommonInvariants (LeaderboardOptInInvariants.cs:1), Result and Error, the UserIdentifierType alias (solution-wide global using, see primer §2), and the LeaderboardOptIn.DisplayNameMaxLength constant.
  • Concept introduced, the static invariant class. [Rubric §4, Domain-Driven Design] assesses whether business rules live in the model rather than leaking into handlers or the database schema alone. Every aggregate in this module pairs with a static class whose methods each return a Result, so a factory can compose them with Result.Combine and report every violation at once instead of failing on the first. [Rubric §1, SOLID]: one method, one rule, one reason to change. The idiom is the same one the framework value objects use in Group 02; the three siblings in this unit (CheckInInvariants, PointsEntryInvariants and UserSessionBookmarkInvariants) are the same shape with different rules.
  • Walkthrough: two expression-bodied methods, both taking a source string that the caller passes as its own method name so a failure carries its origin without a stack trace.
    • EnsureUserIdIsValid(userId, source) (:15-16): delegates to CommonInvariants.EnsureIdIsNotDefault with code "LeaderboardOptIn.UserId.Invalid" and message "User ID must be provided.". A default id (zero or empty, whichever the alias resolves to) fails.
    • EnsureDisplayNameIsValid(displayName, source) (:22-29): fails when the name is null, empty, whitespace, or longer than LeaderboardOptIn.DisplayNameMaxLength, returning Error.Invariant with code "LeaderboardOptIn.DisplayName.Invalid" and an interpolated message that reads the constant rather than hardcoding 100 (:26).
  • Why it's built this way: the length rule is stated once as a constant on the aggregate and consumed in three places (this check, the EF column at LeaderboardOptInConfiguration.cs:28-30, and the error message), so widening the column cannot silently disagree with the domain rule. Validating the name in the domain rather than only at the API boundary matters because LeaderboardOptIn.Reactivate also writes it (LeaderboardOptIn.cs:89), and that path never passes through a request validator.
  • Where it's used: called by LeaderboardOptIn.Create (LeaderboardOptIn.cs:64-66) and by Reactivate (LeaderboardOptIn.cs:89).

PointsEntryInvariants

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntryInvariants.cs:10 · Level 6 · class (static)

  • What it is: the four-rule invariant helper for PointsEntry. It is the widest invariant class on the points side of the module, because a ledger row has four things that can be wrong: the attendee, the activity, the amount, and the subject the award is scoped to.
  • Depends on: CommonInvariants, Result and Error, PointsActivityType and PointsSubjectKeys from MMCA.ADC.Engagement.Shared (PointsEntryInvariants.cs:1), and the UserIdentifierType alias. Externals: System.Enum.
  • Concept: the static invariant class is taught on LeaderboardOptInInvariants. What is worth teaching here is the boundary between a rule and a kill switch. [Rubric §4, Domain-Driven Design]: a zero-point award is rejected outright here (:26-29), because turning an earn rule off is the awarder's job, not the ledger's. A rule configured to 0 short-circuits inside PointsAwarder before any entity is built (PointsAwarder.cs:45-50), so a zero reaching this factory can only be a caller bug, which is exactly what the doc comment states (:19-22).
  • Walkthrough: four methods, same shape as the siblings.
    • EnsureUserIdIsValid(userId, source) (:16-17): CommonInvariants.EnsureIdIsNotDefault, code "PointsEntry.UserId.Invalid".
    • EnsurePointsArePositive(points, source) (:26-29): points > 0 or Error.Invariant("PointsEntry.Points.Invalid", ...).
    • EnsureSubjectKeyIsValid(subjectKey, source) (:38-45): rejects null, empty, whitespace, or longer than PointsSubjectKeys.MaxLength (64, PointsSubjectKeys.cs:13), with code "PointsEntry.SubjectKey.Invalid". The comment (:31-33) explains why this is more than cosmetic: the key is part of the unique index, so a truncated or blank key would break idempotency rather than merely look wrong.
    • EnsureActivityTypeIsDefined(activityType, source) (:51-54): Enum.IsDefined(activityType), code "PointsEntry.ActivityType.Invalid". [Rubric §15, Best Practices and Code Quality]: PointsActivityType deliberately starts at 1 and reserves 0 for "no activity" (PointsActivityType.cs:11-15), so this one check turns a defaulted field or an unset payload into a validation failure instead of a silently mis-attributed award.
  • Why it's built this way: three of the four rules exist to protect the ledger's unique index on (UserId, ActivityType, SubjectKey) (PointsEntryConfiguration, PointsEntryConfiguration.cs:46-48). Idempotency and anti-farming both rest on that index, and an index cannot defend itself against a blank or truncated component, so the domain does.
  • Where it's used: called by PointsEntry.Create (PointsEntry.cs:83-87), combined through Result.Combine. Unit-tested by PointsEntryInvariantsTests.

UserSessionBookmarkInvariants

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.UserSessionBookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmarkInvariants.cs:9 · Level 6 · class (static)

  • What it is: the static invariant helper for UserSessionBookmark. Two rules, both guarding that the aggregate's cross-module foreign keys are actually set before a bookmark can be constructed.
  • Depends on: CommonInvariants (UserSessionBookmarkInvariants.cs:1), Result, and the UserIdentifierType / SessionIdentifierType aliases (solution-wide global using, see primer §2).
  • Concept: the static invariant class is taught on LeaderboardOptInInvariants. This is the minimal instance of it: no lengths, no enums, just the two identifiers. [Rubric §1, SOLID]: each method has exactly one reason to change.
  • Walkthrough: two one-line methods.
    • EnsureUserIdIsValid(userId, source) (:11-12): forwards to CommonInvariants.EnsureIdIsNotDefault with code "UserSessionBookmark.UserId.Invalid" and message "User ID must be provided.".
    • EnsureSessionIdIsValid(sessionId, source) (:14-15): the same shape for SessionId, code "UserSessionBookmark.SessionId.Invalid".
    • Both take the source string the caller fills with its own method name, so a failure carries the originating call site.
  • Why it's built this way: enforcing "a bookmark must reference a real user and a real session" in the domain, not only through a database NOT NULL, means an invalid bookmark can never be materialized. Because SessionId and UserId point at rows in other services' databases (database-per-service, ADR-006), there is no cross-database foreign key to lean on, so the not-default check is the domain's own front line.
  • Where it's used: called by UserSessionBookmark.Create (UserSessionBookmark.cs:44-46), combined through Result.Combine.
  • Caveats / not-in-source: unlike its siblings in this unit, this class carries no XML doc comments on its members (UserSessionBookmarkInvariants.cs:11-15); the intent has to be read off the error codes.

AttendeeBadgeInvariants

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Badges · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadgeInvariants.cs:9 · Level 6 · class (static)

  • What it is: the one invariant rule for AttendeeBadge: a badge must be bound to a real user.
  • Depends on: CommonInvariants (AttendeeBadgeInvariants.cs:16) and Result.
  • Concept: the static invariant class beside its aggregate is taught in group 02. [Rubric §4, DDD] assesses whether invariants are stated where the model can enforce them: keeping them in a static class that the factory composes means each rule is individually named, individually testable, and reusable by any future mutator on the same aggregate.
  • Walkthrough: one method. EnsureUserIdIsValid(userId, source) (:15-16) delegates to CommonInvariants.EnsureIdIsNotDefault with the stable error code "AttendeeBadge.UserId.Invalid", the message, the calling member name for attribution, and nameof(userId) as the target. The source parameter is passed by its caller as nameof(Create) (AttendeeBadge.cs:42), which is what puts the failing member into the error rather than a stack trace.
  • Why it's built this way: a badge carries almost no state (an owner and an opaque credential, AttendeeBadge.cs:21-24), and the credential is generated internally, so there is exactly one thing a caller can get wrong. The class is still written out rather than inlined into the factory so the badge follows the same shape as every other aggregate in the module, including its much larger sibling CheckInInvariants.
  • Where it's used: AttendeeBadge.Create (AttendeeBadge.cs:42) is the only caller in the module.

PointsEntry

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:31 · Level 7 · class (sealed)

  • What it is: the aggregate root recording one points award, and the whole of the points ledger. An attendee's total is never a stored number: it is the sum of their entries.
  • Depends on: AuditableAggregateRootEntity<TIdentifierType> (bound to PointsEntryIdentifierType), IAuditedEntity, PointsEntryInvariants, the PointsEntryChanged domain event, PointsActivityType and PointsSubjectKeys, DomainEntityState, IdValueGeneratedAttribute, and Result.
  • Concept introduced, the append-only ledger. [Rubric §8, Data Architecture] assesses whether the schema states the intended semantics rather than relying on convention. This type has a factory and no mutators at all: every property has a private set and nothing inside the class ever writes one after construction (PointsEntry.cs:33-46). A total is therefore always derivable and never a number somebody edited (:11-13). [Rubric §4, Domain-Driven Design]: immutability is the model's statement that an award is a historical fact, not a mutable balance.
  • Concept introduced, the value snapshot. Points stores the configured award as it stood at award time (:39-40), resolved by the caller before it reaches the factory (:72). [Rubric §15, Best Practices & Code Quality] and [Rubric §8, Data Architecture]: retuning the economy mid-conference changes what the next award is worth and never rewrites history (:14-17). The alternative (storing only the activity and multiplying by today's configured value at read time) would silently restate every past award every time an operator changed a setting.
  • Concept introduced, idempotency and anti-farming in one index. [Rubric §12, Performance and Scalability] and [Rubric §11, Security] both apply. The unique index on (UserId, ActivityType, SubjectKey) (PointsEntryConfiguration, PointsEntryConfiguration.cs:46-48) is the real rule behind both properties (:18-22): a replayed award collides with the row it already wrote, and N questions asked in one session collapse onto one subject key (session:{id}, PointsSubjectKeys.cs:24-25) so they award exactly once. Neither guarantee is implemented by counting in application code. PointsAwarder adds a pre-check for the ordinary duplicate (PointsAwarder.cs:56-63) and reads the index violation from a concurrent race as already-awarded rather than as a failure (PointsAwarder.cs:74-81, via DuplicateKeyDetection).
  • Concept, the audit marker on a ledger. [Rubric §13, Observability and Operability] assesses whether operationally load-bearing writes leave a trail. The class implements IAuditedEntity (:31) for a stated reason (:23-28): the ledger decides a prize-bearing leaderboard, so the append-only rule is worth being able to prove rather than merely assert. With the trail (ADR-075), an insert that was never followed by an update is visible in the data, and the one write that does move a total (a soft-delete or erasure of an entry) is recorded with it.
  • Walkthrough
    • [IdValueGenerated] (:30): database-generated id, so the factory writes Id = default (:93).
    • Five private set properties: UserId (:34, a cross-database scalar), ActivityType (:37), Points (:40), SubjectKey (:43, defaulted to string.Empty so the EF constructor never leaves it null), OccurredOnUtc (:46, supplied by the caller's TimeProvider rather than read from a clock here).
    • Two constructors: EF-only parameterless (:49) and the private assigning one (:51-63).
    • Create(userId, activityType, points, subjectKey, occurredOnUtc) (:76-104): Result.Combine over all four invariants (:83-87), failure re-wrapped as Result.Failure<PointsEntry> (:88-89), the entity built with Id = default (:91-94), then PointsEntryChanged(DomainEntityState.Added, id, userId, activityType, points) raised (:96-101). There is no Update, no Adjust, and no Delete override: correcting an award means writing the compensating history, not editing the row.
  • Why it's built this way: the contract is deliberately conference-agnostic. Neither this entity nor IPointsAwarder names an event, a session, or a check-in: an award is a user, an activity, an opaque subject key, and a timestamp (IPointsAwarder.cs:10-17). [Rubric §7, Microservices Readiness]: that is what would make lifting the ledger into MMCA.Common a move rather than a rewrite, leaving only the thin award adapters behind in ADC. The scan surfaces that feed it are described in ADR-072.
  • Where it's used: written only by PointsAwarder (PointsAwarder.cs:65, the single write path into the ledger); read by GetMyPointsHandler (GetMyPointsHandler.cs:54), GetPointsOverviewHandler (GetPointsOverviewHandler.cs:40) and GetLeaderboardHandler (GetLeaderboardHandler.cs:54), all projecting to PointsEntryDTO rather than returning the aggregate; exported by UserEngagementExportService; persisted per PointsEntryConfiguration, which adds a UserId-leading index so the "my points" read is a seek (PointsEntryConfiguration.cs:51-52). Unit-tested by PointsEntryTests.

UserSessionBookmark

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.UserSessionBookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:16 · Level 7 · class (sealed)

  • What it is: the aggregate root of the session-bookmark feature, one row per user's saved session (a personal-schedule entry). It holds two scalar foreign keys, UserId and SessionId, and nothing else: its whole behavior is a create/reactivate/delete lifecycle expressed through a single domain event.
  • Depends on: AuditableAggregateRootEntity<TIdentifierType> (bound to UserSessionBookmarkIdentifierType), UserSessionBookmarkInvariants, the UserSessionBookmarkChanged domain event, DomainEntityState, IdValueGeneratedAttribute, and Result.
  • Concept introduced, one domain event with a state enum (BR-60). [Rubric §4, Domain-Driven Design] and [Rubric §6, CQRS and Event-Driven] assess whether aggregates own their invariants and announce state changes as events. This aggregate is the module's clearest example of the deliberate "one event, many states" choice: rather than separate BookmarkCreated and BookmarkDeleted types, every lifecycle transition raises a single UserSessionBookmarkChanged carrying a DomainEntityState discriminator (Added or Deleted), documented in the class remarks (UserSessionBookmark.cs:12-13). Downstream consumers subscribe to one signal and branch on the enum. That is the framework-wide taxonomy decision recorded in ADR-083; LeaderboardOptIn follows the same pattern.
  • Concept, the reactivation lifecycle (BR-135). Re-bookmarking a previously removed session must revive the soft-deleted row rather than insert a second one. The database half of that rule is UserSessionBookmarkConfiguration's soft-delete-filtered unique index (UserSessionBookmarkConfiguration.cs:32-34, the convention behind it taught on SoftDeleteUniqueIndexConvention and decided in ADR-095); the decision half is BookmarkManagementDomainService.
  • Walkthrough
    • [IdValueGenerated] (UserSessionBookmark.cs:15): marks the id as database-generated, so the factory leaves Id = default and SQL Server's IDENTITY fills it.
    • UserId / SessionId (:19, :22): private set scalar FKs. They are not navigations: the referenced rows live in the Identity and Conference databases (database-per-service, ADR-006), so a navigation would cross a service boundary.
    • Two constructors, an EF-only parameterless one (:25) and a private (userId, sessionId) one (:27-31). Neither is callable from outside: construction runs through the factory.
    • Create(userId, sessionId) (:40-60): combines both invariants via Result.Combine (:44-46); on failure re-wraps the errors as Result.Failure<UserSessionBookmark> (:47-48); on success builds the entity with Id = default (:50-53) and raises UserSessionBookmarkChanged(DomainEntityState.Added, ...) (:57). The comment above that call (:55-56) is the one subtle line in the file: the id is still 0 at this point because the IDENTITY value is assigned by the INSERT, and the event captures it by value, so consumers correlate on the user and the session rather than on the bookmark's own id.
    • Reactivate() (:68-76): calls the inherited Undelete() (:70) and, only if that succeeds, re-raises the same Added event (:73). The row keeps its identity and audit trail. Note the contrast with LeaderboardOptIn.Reactivate, which takes a display name and refreshes it: a bookmark carries no snapshot to refresh, so this overload takes no arguments.
    • Delete() (:83-91): overrides the base soft-delete, calls base.Delete() first (:85) and, on success, raises UserSessionBookmarkChanged(DomainEntityState.Deleted, ...) (:88).
  • Why it's built this way: reactivation over delete-then-insert preserves referential continuity and the audit trail, consistent with the soft-delete-everywhere policy (ADR-005). Funnelling create and reactivate through the same Added event means consumers see one uniform "this bookmark is now active" signal regardless of whether the row is new or revived.
  • Where it's used: created and reactivated by BookmarkManagementDomainService; counted by BookmarkCountService; mapped by UserSessionBookmarkDTOMapper; persisted per UserSessionBookmarkConfiguration; the aggregate type parameter for the CRUD surface on BookmarksController. Unit-tested by UserSessionBookmarkTests.

AttendeeBadge

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Badges · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:18 · Level 7 · class (sealed)

  • What it is: the aggregate root holding an attendee's badge credential, the opaque value encoded into their QR badge and the only thing an organizer's scan carries.
  • Depends on: AuditableAggregateRootEntity<TIdentifierType> (the base, :18), AttendeeBadgeInvariants (:42), Result, and IdValueGeneratedAttribute (:17), which tells the persistence layer the database assigns the id. Externals: System.Guid.
  • Concept introduced, the opaque bearer credential. [Rubric §11, Security] assesses how a credential is issued, verified and revoked. The design decision is written in the class comment (:7-11): the credential is server-verified on every scan, so it needs no signature and no expiry of its own, and revoking a leaked badge is a Regenerate away. The most instructive line in the file is the private helper comment (:65-67): Guid.NewGuid() is chosen over Guid.CreateVersion7() precisely because a v7 value embeds its creation timestamp and orders monotonically, which is the property a guessable bearer value must not have. That is a case where the newer API is the wrong one, and the file says so rather than leaving the next reader to "modernize" it. [Rubric §4, DDD] covers the aggregate shape: private setters, a private EF constructor, a static factory returning Result<T>, and a named mutator instead of a public property setter.
  • Walkthrough
    • Two properties: UserId (:21), one badge per user, and Credential (:24), the Guid encoded into the QR.
    • The parameterless private constructor (:27) exists for EF Core materialization; the private assigning constructor (:29-33) is what the factory uses.
    • Create(userId) (:40) validates through AttendeeBadgeInvariants (:42), returns the failure unchanged when it fails (:43-44), then builds the badge with a fresh credential and Id = default (:46-49) so the store assigns the key. It raises no domain event, and the class comment (:12-15) explains that too: a badge existing says nothing a downstream module needs to react to, because the interesting fact is the check-in, which CheckIn publishes.
    • Regenerate() (:59-63) replaces the credential in place and returns success, invalidating every previously issued copy: a printout left on a table, a screenshot shared in a chat (:54-57).
    • NewCredential() (:68) is the one place a credential is minted.
  • Why it's built this way: the badge is deliberately the thinnest possible aggregate, because everything expensive (who may scan, whether the event is running, whether this is a duplicate) belongs to the check-in write path rather than to the credential. Keeping the credential opaque and server-verified is the decision recorded in ADR-072, which also fixes the encoded form the scanner reads.
  • Where it's used: minted on first use by GetOrCreateMyBadgeHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:41) behind CheckInsController's my-badge endpoint; read back by CheckInAttendeeHandler, which first extracts the credential from the scanned payload through BadgePayload (.../CheckInAttendee/CheckInAttendeeHandler.cs:43) and then resolves it to an attendee (:50). Persisted by AttendeeBadgeConfiguration (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/AttendeeBadgeConfiguration.cs:15), which puts a unique index on both the owner and the credential (:31, :35), and exposed as a DbSet on the module context (.../Persistence/DbContexts/ModuleApplicationDbContext.cs:30). It deliberately gets no INavigationPopulator (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:81-83) because it has no navigation. Covered by AttendeeBadgeTests.
  • Caveats / not-in-source: Regenerate() has no production call site today. The only callers in the repository are the domain tests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Domain.Tests/Badges/AttendeeBadgeTests.cs:54, :67, :77): the revocation path exists on the model but no endpoint or handler invokes it.

BookmarkCountService

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/Services/BookmarkCountService.cs:11 · Level 8 · class (sealed)

  • What it is: the in-process implementation of the cross-module IBookmarkCountService, answering "how many active bookmarks does this session have?" for the Conference module, one session at a time or a whole set at once.
  • Depends on: IUnitOfWork, IQueryableExecutor, UserSessionBookmark, IBookmarkCountService. Externals: LINQ and System.Collections.Generic.
  • Concept, the cross-module read boundary. [Rubric §7, Microservices Readiness] assesses whether modules talk through explicit, extractable contracts rather than direct references. Conference must show a per-session bookmark count but must not reference Engagement's domain; it depends only on IBookmarkCountService, which lives in MMCA.ADC.Engagement.Shared. In process this class satisfies that contract; once Engagement runs as its own service, BookmarkCountServiceGrpcAdapter satisfies it over the wire and no Conference call site changes. This is one direction of the bidirectional Conference/Engagement pair (Engagement in turn calls Conference's ISessionBookmarkValidationService).
  • Concept introduced, the batch method that replaces a caller's fan-out. [Rubric §12, Performance and Scalability] assesses whether read paths avoid N+1 round trips. The second method exists because the conference-day session list needs a count per session, and calling the single-session method in a loop would issue one COUNT per row. GetBookmarkCountsForSessionsAsync pushes one grouped COUNT for the whole set (BookmarkCountService.cs:37-43) and then guarantees a complete result map, so the caller never has to distinguish "zero bookmarks" from "session missing from the response" (:47-50).
  • Walkthrough
    • The primary constructor injects IUnitOfWork and IQueryableExecutor (:11). Note that no repository is constructor-injected: repositories are resolved per call off the unit of work, which is the framework's rule.
    • GetBookmarkCountForSessionAsync(sessionId, cancellationToken) (:14-22): resolves the typed repository via unitOfWork.GetRepository<UserSessionBookmark, UserSessionBookmarkIdentifierType>() (:18) and returns bookmarkRepo.CountAsync(b => b.SessionId == sessionId, cancellationToken) (:19-21). The count is a COUNT pushed to the database with no rows materialized, and the soft-delete global query filter means only active bookmarks count.
    • GetBookmarkCountsForSessionsAsync(sessionIds, cancellationToken) (:25-51): guards null with ArgumentNullException.ThrowIfNull (:29), short-circuits an empty request to an empty dictionary without touching the database (:30-33), then takes the read repository (:35) and composes TableNoTracking.Where(...).GroupBy(...).Select(...) (:39-42) handed to queryableExecutor.ToListAsync (:38-43). The grouped rows become a dictionary (:45), and the final projection walks the requested ids Distinct() and fills a 0 for any session the group-by returned nothing for (:48-50).
    • The IQueryableExecutor indirection is what keeps this Application-layer class free of a direct EF Core dependency while still materializing an EF query: the provider-specific ToListAsync lives behind the abstraction. [Rubric §3, Clean Architecture].
  • Why it's built this way: exposing purpose-built count methods (rather than letting Conference query bookmarks) keeps the read cheap and the coupling to two stable signatures, which is what makes the gRPC swap a thin adapter. The batch method is paired with a schema decision: UserSessionBookmarkConfiguration adds a SessionId-leading index precisely because counting by SessionId alone cannot seek the (UserId, SessionId) composite (UserSessionBookmarkConfiguration.cs:36-39).
  • Where it's used: registered TryAddScoped by the Engagement Application DI (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:46); wrapped for the wire by BookmarkCountsGrpcService; consumed by Conference's GetSessionBookmarkCountHandler and GetSessionBookmarkCountsHandler. When Engagement is disabled in a host, DisabledBookmarkCountService stands in until the gRPC adapter replaces the registration.

IBookmarkManagementDomainService

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.UserSessionBookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/IBookmarkManagementDomainService.cs:11 · Level 8 · interface

  • What it is: a pure domain-service contract for the create-or-reactivate lifecycle of a session bookmark (BR-135). Given a possibly null previously soft-deleted bookmark plus the acting user and session, it returns the active bookmark, either the reactivated old row or a brand-new one (IBookmarkManagementDomainService.cs:5-10).
  • Depends on: Result, UserSessionBookmark, and the UserIdentifierType / SessionIdentifierType aliases (solution-wide global using, see primer §2). No EF, no repository, no CancellationToken.
  • Concept introduced, the domain service. [Rubric §4, Domain-Driven Design] assesses whether business logic lives in the model rather than leaking into handlers or infrastructure. A domain service captures a rule that does not sit naturally on one entity or value object but is still pure domain (no I/O). Here the rule "if a soft-deleted bookmark already exists for this user and session, revive it instead of inserting a second row" spans a persistence-shaped concern (a hidden row exists) yet is expressed entirely over domain entities the application layer has already fetched. Keeping it behind an interface makes it injectable and trivially unit-testable. [Rubric §1, SOLID]: the single method has one reason to change, the reactivate-versus-create decision.
  • Walkthrough: one method, CreateOrReactivate(existingDeletedBookmark?, userId, sessionId) returning Result<UserSessionBookmark> (:21-24). The nullable first parameter is the whole design: null means the application layer found no prior soft-deleted record, non-null means it found one (fetched with ignoreQueryFilters: true so the soft-delete filter does not hide it, CreateBookmarkHandler.cs:50-56). Everything the service needs is passed in, so it touches no repository and returns synchronously.
  • Why it's built this way: pushing the branch into the domain keeps the application handler thin (the handler does the query, the service makes the decision) and keeps the decision testable without a database. The service is deliberately infrastructure-free so it stays inside the Domain layer without violating the dependency rule (see primer §1). [Rubric §14, Testability].
  • Where it's used: implemented by BookmarkManagementDomainService, registered TryAddSingleton (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:38), and injected by CreateBookmarkHandler (CreateBookmarkHandler.cs:20).

UserSessionBookmarkDTOMapper

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.UserSessionBookmarks.DTOs · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DTOs/UserSessionBookmarkDTOMapper.cs:12 · Level 8 · class (sealed partial)

  • What it is: the Mapperly-generated mapper that projects a UserSessionBookmark aggregate to its wire-facing UserSessionBookmarkDTO.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (closed over the bookmark triple, UserSessionBookmarkDTOMapper.cs:13), UserSessionBookmark, UserSessionBookmarkDTO. Externals: Riok.Mapperly.Abstractions (the [Mapper] source generator).
  • Concept, compile-time DTO mapping with Mapperly (ADR-001). [Rubric §2, Design Patterns] and [Rubric §15, Best Practices and Code Quality] assess mapping that is explicit and allocation-cheap rather than reflection based. The [Mapper] attribute (:11) makes Mapperly generate the body of the partial method at compile time, so there is no runtime reflection and a shape mismatch is a build error rather than a silent null (the framework-wide manual-mapping versus Mapperly rationale is taught in Group 12).
  • Walkthrough (:12-24): the class implements the shared IEntityDTOMapper contract. MapToDTO(entity) (:16) is declared partial and Mapperly writes the property-by-property copy. MapToDTOs(collection) (:19-23) is hand-written: it guards null with ArgumentNullException.ThrowIfNull and returns [.. entityCollection.Select(MapToDTO)], a collection-expression materialization.
  • Why it's built this way: a source-generated single-item map plus a tiny hand-written collection wrapper keeps the hot path reflection-free while still satisfying the batch signature the query pipeline expects. sealed partial is mandatory: partial lets the generator supply the method body, sealed keeps the type closed.
  • Where it's used: auto-registered by the module's convention scan and injected directly by CreateBookmarkHandler (CreateBookmarkHandler.cs:21, used at :90) and GetUserBookmarksHandler (GetUserBookmarksHandler.cs:21, used at :72); also resolved by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for bookmarks (DependencyInjection.cs:42) behind BookmarksController. Unit-tested by UserSessionBookmarkDTOMapperTests.

BookmarkManagementDomainService

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.UserSessionBookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/BookmarkManagementDomainService.cs:9 · Level 9 · class (sealed)

  • What it is: the single implementation of IBookmarkManagementDomainService. A sealed, dependency-free class that decides between reactivating a soft-deleted bookmark and creating a fresh one.
  • Depends on: IBookmarkManagementDomainService, UserSessionBookmark, Result. No injected collaborators: the constructor is implicit (BookmarkManagementDomainService.cs:9).
  • Concept, soft-delete meets a filtered unique index. [Rubric §8, Data Architecture] assesses deliberate schema semantics. UserSessionBookmarkConfiguration declares a unique index on (UserId, SessionId) filtered to the soft-delete flag (UserSessionBookmarkConfiguration.cs:32-34), so a second active row for the same pair is impossible, but a soft-deleted row still occupies that pair's history. That is exactly why you cannot blindly Create on a re-bookmark: you must flip the existing row back to active. This service is the domain half of that dance and the index is the database half, applied automatically by SoftDeleteUniqueIndexConvention (ADR-095). The same pairing appears one aggregate over on LeaderboardOptIn, where the reactivate branch lives in the handler instead.
  • Walkthrough (:13-28)
    • If existingDeletedBookmark is not null (:18): call existingDeletedBookmark.Reactivate() (:20), which on the aggregate calls the inherited Undelete() and re-raises UserSessionBookmarkChanged(DomainEntityState.Added, ...) (UserSessionBookmark.cs:68-76). If reactivation fails, its errors are propagated as Result.Failure<UserSessionBookmark>(reactivateResult.Errors) (:21-22); otherwise the revived entity is returned via Result.Success(...) (:24).
    • If null: delegate to the factory UserSessionBookmark.Create(userId, sessionId) (:27), which validates invariants and raises the same Added event (UserSessionBookmark.cs:40-60).
  • Why it's built this way: reactivation (not delete-then-insert) preserves the row's identity, its audit trail (CreatedOn/By), and any scalar references that point at it, consistent with the soft-delete-everywhere policy (ADR-005). Both branches funnel through the same Added domain event so downstream consumers see one uniform "bookmark is now active" signal (BR-60, a single UserSessionBookmarkChanged carrying DomainEntityState rather than separate Created/Changed events, ADR-083). Being state-free is what lets it be registered as a singleton (DependencyInjection.cs:38).
  • Where it's used: CreateBookmarkHandler calls it after querying for a soft-deleted match (CreateBookmarkHandler.cs:59), and only adds the returned entity to the repository when there was no prior row to revive (CreateBookmarkHandler.cs:64-67). A concurrent insert that gets past the pre-check surfaces as the unique-index violation the handler translates back into the same conflict error via DuplicateKeyDetection (CreateBookmarkHandler.cs:73-85).

CheckIn

MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:28 · Level 10 · class (sealed)

  • What it is: the aggregate root recording that an attendee was checked in, by an organizer scanning their QR badge, through the manual fallback, or by the attendee themselves scanning a printed sponsor or room QR. One aggregate carries all three scopes.
  • Depends on: AuditableAggregateRootEntity<TIdentifierType> and IAuditedEntity (both on :28), CheckInInvariants (:99-103), CheckInScope (:34), CheckInScopeNames (:114), AttendeeCheckedIn (:112), Result and IdValueGeneratedAttribute (:27). Externals: DateTimeOffset.
  • Concept introduced, one aggregate for a family of shapes. [Rubric §4, Domain-Driven Design] assesses aggregate boundaries. The tempting alternative is three aggregates (session check-in, sponsor visit, room check-in), and the class comment (:10-15) argues against it from the behavior: the row, the idempotency rule and the attendance query are the same shape for each, only the required target differs, and self-recorded rows stay distinguishable by CheckedInByUserId. The cost of that choice is that "which target is legal for which scope" becomes an invariant instead of a type, which is exactly what CheckInInvariants.EnsureTargetMatchesScope exists for.
  • Concept, an integration event raised from inside the aggregate. [Rubric §6, CQRS and Event-Driven] assesses where an announcement is produced. AddDomainEvent is called inside the factory (:112-119), not by a handler, and the payload is AttendeeCheckedIn, which derives from BaseIntegrationEvent rather than from a plain domain event. Because the aggregate collects it before SaveChangesAsync runs, the outbox captures the announcement in the same transaction as the row (ADR-003): the check-in and its cross-module event either both land or neither does.
  • Concept, the audit marker on an attendance assertion. [Rubric §30, Compliance, Privacy and Data Governance] covers the IAuditedEntity marker, whose reason is written out (:21-25): a check-in is an attendance assertion about a named person that feeds the points economy, so a disputed or revoked row needs a record of what it looked like before (ADR-075). This is the same marker PointsEntry carries, for the same reason on the other side of the earn path.
  • Walkthrough (teaching order)
    • Seven private-set properties: UserId (:31), Scope (:34), EventId (:37, always set), the nullable SessionId (:40) and SponsorId (:43), CheckedInByUserId (:49) and CheckedInOn (:52). The two nullable targets plus the scope are the polymorphic part; everything else is present on every row.
    • The parameterless private constructor (:55) is EF's; the assigning private constructor (:57-73) is the factory's.
    • Create (:89-122) takes the scope explicitly and the sponsor id last with a default (:96), which the doc comment (:87) justifies: the scan and manual paths can never carry one, so they stay unchanged as sponsor visits were added.
    • Validation is one Result.Combine of five invariants (:98-103), so a caller gets every violated rule at once rather than the first; a failure returns the errors unchanged (:104-105).
    • Construction sets Id = default (:107-110) so the store assigns the key, matching the [IdValueGenerated] attribute on the class.
    • AddDomainEvent(new AttendeeCheckedIn(...)) (:112-119) projects the aggregate onto the wire contract, converting the enum scope to its stable string with CheckInScopeNames.ToName (:114) and passing the nullable session and sponsor ids straight through.
    • There is no mutator: a check-in is a fact, so the aggregate is create-only.
  • Why it's built this way: the factory doc comment (:75-80) states the guarantee the whole points path leans on: because the event is added before the save, a persisted check-in has always published exactly one event, and because the handler's duplicate short-circuit never reaches this method, a repeat scan publishes none. The second scoping fact is in the class comment (:16-20): the conference runs door and arrival check-in through TicketLeap, so the Event scope is not a door process and session check-in is the working path (ADR-072).
  • Where it's used: created through CheckInProcessor on behalf of CheckInAttendeeHandler and ManualCheckInHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:70, after the duplicate short-circuit at :59-68), and directly by RecordSponsorVisitHandler (.../CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:97, scope Sponsor) and RecordRoomCheckInHandler (.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:99, scope Session with the attendee as their own recorder); read by GetAttendanceStatsHandler (.../CheckIns/UseCases/GetAttendanceStats/GetAttendanceStatsHandler.cs:25-35) and exported by UserEngagementExportService (.../Exports/UserEngagementExportService.cs:51-59). Persisted by CheckInConfiguration, which turns the scope rule into three filtered unique indexes (one event check-in per attendee per event, one per session, one per sponsor: MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:48-62) plus two non-unique indexes for the attendance rollup (:64-69). Its event feeds AttendeeCheckedInPointsHandler. Covered by CheckInTests.
  • Caveats / not-in-source: the duplicate-scan short-circuit the factory comment relies on lives in the use-case handlers and in CheckInProcessor, not in this file. The once-per-sponsor cap that makes a shared deep link worth nothing beyond the first scan is stated in the EF configuration comment (CheckInConfiguration.cs:58-59).

AttendeeBadgeConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/AttendeeBadgeConfiguration.cs:15 · Level 8 · class (internal sealed)

  • What it is: the EF Core mapping for the AttendeeBadge aggregate: two required columns and two unique indexes, one per identity the badge is looked up by (the attendee, and the scannable credential).
  • Depends on: the engine shim base EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> and AttendeeBadge. Externals: EF Core's EntityTypeBuilder<T>.
  • Concept introduced, the module entity configuration over the engine shim. [Rubric §8, Data Architecture] assesses whether the schema is designed for the queries and constraints the application actually needs rather than inherited from conventions; [Rubric §3, Clean Architecture] assesses whether persistence concerns stay out of the domain. Every Engagement configuration in this file group follows one shape: extend EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, call base.Configure(builder) first (AttendeeBadgeConfiguration.cs:21), then declare only what is specific to this entity. The base applies the framework conventions once (table and schema resolution, the soft-delete global query filter, audit columns, the concurrency token); the full hierarchy is taught in Group 07. The engine is the choice of base class, so re-pointing an entity at the Cosmos or SQLite shim is a base-class swap with no body edits (ADR-018); every Engagement entity uses the SQL Server shim today. The domain entity itself carries no EF attributes, which is what keeps MMCA.ADC.Engagement.Domain free of a persistence dependency.
  • Concept introduced, unique index as a race backstop. [Rubric §12, Performance & Scalability] and [Rubric §11, Security]. Both indexes here are unique and unfiltered in the source: HasIndex(b => b.UserId).IsUnique() (:31-32) and HasIndex(b => b.Credential).IsUnique() (:35-36). The first is the concurrency guarantee the get-or-create path leans on (the inline comment at :29-30 says so), the second makes a scan a single seek on a Guid column (AttendeeBadge.Credential, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:24) and forbids two badges sharing a credential. Note what is NOT written here: neither index declares HasSoftDeleteFilter(), because SoftDeleteUniqueIndexConvention already stamps IsDeleted = 0 onto every unique index of a soft-deletable entity at model finalizing (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:60-70) and leaves an index that already carries the predicate untouched (:73-76).
  • Walkthrough (AttendeeBadgeConfiguration.cs:19-37): base.Configure(builder) (:21); UserId required (:23-24); Credential required (:26-27); the unique UserId index (:31-32); the unique Credential index (:35-36). That is the whole file: five statements, each with a comment naming the behavior it protects.
  • Why it's built this way: the remarks (:11-14) state the cross-module rule the whole module obeys, UserId is a scalar reference into the Identity database and never a cross-database FK, because Engagement cannot see the Identity model (database-per-service, ADR-006). [Rubric §7, Microservices Readiness].
  • Where it's used: discovered by assembly scanning and applied when the Engagement SQLServerDbContext builds its model; the unique UserId index is what GetOrCreateMyBadgeHandler classifies through IUniqueConstraintViolationDetector when two requests race (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:54).

LeaderboardOptInConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/Points/LeaderboardOptInConfiguration.cs:17 · Level 8 · class (internal sealed)

  • What it is: the EF Core mapping for LeaderboardOptIn, the row that says an attendee agreed to appear on the public board and under which name. Same base shape as AttendeeBadgeConfiguration; its distinctive job is "one active opt-in per attendee".
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, LeaderboardOptIn, and IndexBuilderExtensions for HasSoftDeleteFilter(). Externals: EF Core's EntityTypeBuilder<T>.
  • Concept introduced, HasSoftDeleteFilter() instead of a SQL literal. [Rubric §8, Data Architecture] and [Rubric §15, Best Practices & Code Quality]. The unique index is declared .IsUnique().HasSoftDeleteFilter() (LeaderboardOptInConfiguration.cs:33-35). That extension member (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:50-64) builds the predicate from the model rather than from a hand-typed "[IsDeleted] = 0", so the column name follows a rename and the quoting comes from the engine; it takes an engine parameter defaulting to DataSource.SQLServer and an optional additionalFilter that is ANDed in front of the soft-delete predicate (IndexBuilderExtensions.cs:61-63). On a unique index the call is belt-and-braces (the convention would have added the same predicate), and it is idempotent in effect because the convention skips any index that already declares the predicate; on a non-unique index it is the only way to get the filter at all.
  • Walkthrough (LeaderboardOptInConfiguration.cs:21-36): base.Configure(builder) (:23); UserId required (:25-26); DisplayName required with HasMaxLength(LeaderboardOptIn.DisplayNameMaxLength) (:28-30), the cap being the domain constant 100 (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:22), so the column width and the invariant in LeaderboardOptInInvariants can never disagree; then the filtered unique index on UserId (:33-35).
  • Why it's built this way: the remarks (:11-16) give both halves of the design. Filtering the unique index on the soft-delete flag is what makes rejoining reactivate the existing row instead of accumulating one dead row per toggle (the path SetLeaderboardParticipationHandler implements). Storing DisplayName here at all is deliberate denormalization: the name is the snapshot the attendee published, so the leaderboard query never has to reach into Identity, which is a cross-database read this topology does not allow. [Rubric §7, Microservices Readiness].
  • Where it's used: applied to the Engagement model by assembly scanning; the snapshot column is read by GetLeaderboardHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetLeaderboard/GetLeaderboardHandler.cs:41), and the unique index is the constraint SetLeaderboardParticipationHandler both navigates and catches (SetLeaderboardParticipationHandler.cs:104-110).

LivePollVoteConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/LivePolls/LivePollVoteConfiguration.cs:17 · Level 8 · class (internal sealed)

  • What it is: the EF Core mapping for the LivePollVote aggregate root, whose centerpiece is the filtered unique index guaranteeing one active vote per user per poll (BR-225).
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> (the shared base that maps audit fields, soft-delete and RowVersion), LivePollVote, and IndexBuilderExtensions for HasSoftDeleteFilter(). Externals: EF Core's EntityTypeBuilder<T>.
  • Concept introduced, the filtered unique index as the backstop for a soft-delete aggregate. [Rubric §8, Data Architecture] assesses whether uniqueness and concurrency are enforced at the storage layer rather than hoped for in application code. Because a vote toggles via soft-delete rather than hard-delete, a naive unique index would permanently block a user from re-voting: the deleted row keeps occupying its unique slot. Scoping the index to active rows is what makes the handler's create-or-reactivate dance race-safe (the remark at LivePollVoteConfiguration.cs:12-16 says exactly that). Note how the scoping is expressed: .HasSoftDeleteFilter() (:37), the framework extension member at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:50-64, rather than a hand-typed HasFilter("[IsDeleted] = 0"). The extension builds the predicate from the model, so a renamed soft-delete column or a different engine follows automatically. On a unique index the call is belt and braces: SoftDeleteUniqueIndexConvention already stamps the same predicate onto every unfiltered unique index of a soft-deletable entity at model finalizing (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:56, :60-70) and recognizes the predicate it finds rather than doubling it (:74-75). Writing it explicitly keeps BR-225 legible in the file that owns it. [Rubric §2, Design Patterns] applies in that the database constraint and the domain Reactivate method are two halves of one idempotent-write pattern; the identical shape guards SessionQuestionUpvote through SessionQuestionUpvoteConfiguration.
  • Walkthrough (LivePollVoteConfiguration.cs:21-41): Configure (:21) calls base.Configure (:23), then requires LivePollId (:25-26), OptionId (:28-29) and UserId (:31-32); note there are no navigations, because a vote is a separate aggregate by design and so carries scalar FKs only. The filtered unique index on { LivePollId, UserId } (:35-37) is BR-225, one active vote per poll and user, with the soft-delete predicate applied through HasSoftDeleteFilter() and the rule named in the comment above it (:34). The second, non-unique index on { LivePollId, OptionId } (:40) supports the grouped COUNT per option that the tally issues (:39).
  • Why it's built this way: the filter is what lets soft-delete and uniqueness coexist, routing it through the shared extension keeps every filtered index in the codebase producing byte-identical SQL, and the second index matches the tally query shape exactly, so results are computed from an index rather than by scanning the vote table.
  • Where it's used: applied by the Engagement SQLServerDbContext at model build; CastVoteHandler relies on the unique index as the final arbiter when two concurrent votes race, and LivePollResultsBuilder is the reader the second index serves. Covered by EngagementEntityConfigurationTests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure.Tests/Persistence/EngagementEntityConfigurationTests.cs).

PointsEntryConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.Points · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/Points/PointsEntryConfiguration.cs:21 · Level 8 · class (internal sealed)

  • What it is: the EF Core mapping for PointsEntry, one row per awarded activity. Its composite unique index is not an optimization: it is the enforcement point for two separate rules at once.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, PointsEntry, PointsSubjectKeys (for the key length), and IndexBuilderExtensions. Externals: EF Core's EntityTypeBuilder<T>.
  • Concept introduced, one index carrying two business rules. [Rubric §8, Data Architecture] and [Rubric §6, CQRS & Event-Driven]. The unique index on (UserId, ActivityType, SubjectKey) (PointsEntryConfiguration.cs:46-48) is simultaneously the idempotency key for an at-least-once award (a replayed integration event collides with the row it already wrote) and the anti-farming rule (N questions or N answers for one session collapse onto one subject key, so they award once). The remarks (:12-20) spell out the two-layer interplay: PointsAwarder pre-checks with an ExistsAsync on exactly those three columns so the ordinary duplicate is a clean no-op (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:55-64), and a concurrent race that gets past the pre-check surfaces here as a provider exception the awarder classifies as already-awarded rather than as a failure (PointsAwarder.cs:75). The database is the arbiter of last resort, not the first line.
  • Walkthrough (PointsEntryConfiguration.cs:25-53): base.Configure(builder) (:27); UserId (:29-30), ActivityType (:32-33) and Points (:35-36) required; SubjectKey required with HasMaxLength(PointsSubjectKeys.MaxLength) (:38-40), that is 64 characters (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Points/PointsSubjectKeys.cs:14), a bounded key being what makes it indexable at all; OccurredOnUtc required (:42-43); then the filtered unique triple (:46-48) and a second, non-unique UserId index also carrying HasSoftDeleteFilter() (:51-52) so the "my points" ledger read is a seek. That second index is the case the convention does not cover: it is not unique, so without the explicit call it would carry no filter.
  • Why it's built this way: pushing idempotency into a unique constraint means correctness does not depend on the broker delivering exactly once, which it does not. [Rubric §29, Resilience & Business Continuity]. As everywhere in this module, UserId stays a scalar column, never a cross-database FK (:18-19).
  • Where it's used: applied by assembly scanning; the index is the contract PointsAwarder is written against, and the ledger it protects is read by GetLeaderboardHandler and the my-points queries, and exported by UserEngagementExportService (UserEngagementExportService.cs:36-46).

SessionQuestionConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.SessionQuestions · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/SessionQuestions/SessionQuestionConfiguration.cs:16 · Level 8 · class (internal sealed)

  • What it is: the EF Core mapping for the SessionQuestion aggregate root (the audience-questions half of the live layer): scalar FK columns, a length constraint, and one query-shaped composite index.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionQuestion, and SessionQuestionInvariants for the shared length constant. Externals: EF Core's EntityTypeBuilder<T>.
  • Walkthrough (SessionQuestionConfiguration.cs:20-39): base.Configure(builder) (:22); SessionId required (:24-25); UserId required (:27-28); Text required with HasMaxLength(SessionQuestionInvariants.TextMaxLength) (:30-32), a constant that itself forwards to the DTO's TextMaxLength (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:13), that is 500 characters (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionDTO.cs:18), so the column width, the domain validation and the client-side hint share one source of truth; Status required (:34-35); then HasIndex(p => new { p.SessionId, p.Status }) (:38) because both the attendee list and the moderation queue filter by session and status. This index is deliberately neither unique nor soft-delete-filtered: the question feed has no uniqueness rule to enforce, and the read that uses it is already narrowed by session.
  • Why it's built this way, cross-module FKs stay scalar: the remarks (:10-15) state it plainly. SessionId/EventId point at Conference-owned rows in another database and UserId at an Identity-owned row, so they stay indexed scalar columns; consistency flows through the Conference gRPC validation boundary, never a cross-database constraint. [Rubric §7, Microservices Readiness], [Rubric §8, Data Architecture]. See AttendeeBadgeConfiguration for the shared base-class story.
  • Where it's used: applied to the Engagement model by assembly scanning; the questions it maps are the second section of the data-subject export assembled by UserEngagementExportService (UserEngagementExportService.cs:30-34).

SessionQuestionUpvoteConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.SessionQuestions · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/SessionQuestions/SessionQuestionUpvoteConfiguration.cs:17 · Level 8 · class (internal sealed)

  • What it is: the EF Core mapping for SessionQuestionUpvote. Same shape as SessionQuestionConfiguration, specialized to enforce one active upvote per question per user.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionQuestionUpvote, IndexBuilderExtensions. Externals: EF Core's EntityTypeBuilder<T>.
  • Walkthrough (SessionQuestionUpvoteConfiguration.cs:21-38): base.Configure(builder) (:23); SessionQuestionId required (:25-26); UserId required (:28-29); the filtered unique index on (SessionQuestionId, UserId) (:32-34), which is BR-235 as a database guarantee (named in the summary at :10 and again at the index, :31), at most one active upvote per (question, user), while soft-deleted history may hold prior toggles; and a second, non-unique SessionQuestionId index (:37) for counting upvotes grouped by question. Note the asymmetry with PointsEntryConfiguration: that second index carries no HasSoftDeleteFilter(), so it indexes deleted rows too.
  • Why it's built this way: the filtered unique index is the database-level backstop behind the handler's create-or-reactivate logic (remarks, :12-16), the same soft-delete-plus-unique-index interplay UserSessionBookmarkConfiguration sets up for bookmarks. [Rubric §8, Data Architecture].
  • Caveats / not-in-source: the remarks (:13-14) note upvotes reference their question by a scalar FK column with no navigations, they are a separate aggregate by design, so the index is the only structural link.

UserSessionBookmarkConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.UserSessionBookmarks · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/UserSessionBookmarks/UserSessionBookmarkConfiguration.cs:17 · Level 8 · class (internal sealed)

  • What it is: the EF Core mapping for UserSessionBookmark, the module's founding aggregate. Same base shape as the siblings above; it enforces BR-21 (one active bookmark per user per session) and adds the index the conference-day count path depends on.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, UserSessionBookmark, IndexBuilderExtensions. Externals: EF Core's EntityTypeBuilder<T>.
  • Concept, indexing for the hot read, not just for the constraint. [Rubric §12, Performance & Scalability]. The second index (UserSessionBookmarkConfiguration.cs:38-39) exists because BookmarkCountService counts by SessionId alone, and a leading-UserId composite cannot seek on that predicate; the comment (:36-37) says exactly this. It is non-unique, so HasSoftDeleteFilter() is required rather than optional: the convention only stamps unique indexes (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:62-63), and the count query carries the soft-delete predicate, so an unfiltered index would not match it.
  • Walkthrough (UserSessionBookmarkConfiguration.cs:21-40): base.Configure(builder) (:23); UserId required (:25-26); SessionId required (:28-29); the filtered unique index on (UserId, SessionId) (:32-34); the filtered SessionId index (:38-39).
  • Why it's built this way: filtering the unique index on IsDeleted = 0 lets a user remove and re-add the same bookmark any number of times while guaranteeing only one is active, which is precisely why the bookmark write path reactivates rather than inserts. [Rubric §8, Data Architecture].
  • Caveats / not-in-source: the remarks (:12-16) note the FK relationship to Conference.Session is configured at the shared DbContext level (where both entity types are visible) rather than here, to avoid a cross-module Infrastructure-to-Domain dependency that would break module isolation. In the extracted topology those two entities live in different databases anyway, where CrossDataSourceDegradeConvention drops the constraint.

BookmarkCountServiceGrpcAdapter

MMCA.ADC.Engagement.Contracts · MMCA.ADC.Engagement.Contracts · MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:14 · Level 9 · class (internal sealed)

  • What it is: a hand-written client-side adapter that implements the C# interface IBookmarkCountService on top of the proto-generated gRPC client. It is what Conference resolves for IBookmarkCountService now that Engagement runs as its own process.
  • Depends on: IBookmarkCountService from MMCA.ADC.Engagement.Shared.UserSessionBookmarks (:2); the generated BookmarkCountService.BookmarkCountServiceClient and the request/response messages from bookmark_count.proto (namespace MMCA.ADC.Engagement.Contracts.V1, :1). Externals: the Grpc.Net client factory that supplies the injected client.
  • Concept, the gRPC adapter (same interface, different transport). [Rubric §7, Microservices Readiness] assesses whether modules talk through explicit, extractable contracts; [Rubric §9, API & Contract Design] assesses stable wire contracts. The in-process BookmarkCountService and this remote adapter implement the same interface, so a consumer swaps transport by swapping one DI registration and never edits a call site (ADR-007, ADR-008). Watch the name overlap: the generated proto service type is also called BookmarkCountService (in ...Contracts.V1), and this adapter's primary constructor takes its nested BookmarkCountServiceClient (:14-15); that generated type is distinct from the Application-layer class of the same name.
  • Concept, the per-call deadline as a second, tighter budget. [Rubric §29, Resilience & Business Continuity]. CallDeadline is a static TimeSpan.FromSeconds(5) (:20) applied to both calls (:32, :48), and the comment (:17-19) explains the reasoning: the shared resilience pipeline's 30s attempt / 90s total budget answers a refused peer well, but a hung peer would otherwise stall a caller's request, and bookmark counts sit inline in speaker-dashboard responses. A deadline turns "hung" into a fast DeadlineExceeded status the caller's Polly pipeline can act on.
  • Walkthrough
    • GetBookmarkCountForSessionAsync(sessionId, cancellationToken) (:23-36): builds a GetBookmarkCountForSessionRequest { SessionId = sessionId } (:28-31), passes deadline: DateTime.UtcNow.Add(CallDeadline) plus the caller's token (:32-33), and returns response.Count (:35). The interface returns a plain int, not a Result<int>, so there is no failure trailer to parse: a transport fault surfaces as an RpcException.
    • GetBookmarkCountsForSessionsAsync(sessionIds, cancellationToken) (:39-52): the batch form. It fills the repeated SessionIds field with AddRange (:43-44), sends the same deadline (:48), and rebuilds a dictionary from the repeated response pairs with response.Counts.ToDictionary(c => c.SessionId, c => c.Count) (:51). This is the N+1 avoidance for a speaker dashboard listing many sessions: one round trip instead of one per session.
  • Where it's used: registered by DependencyInjection.AddEngagementBookmarkCountClient(), which the Conference service calls at MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350. Its server-side twin is BookmarkCountsGrpcService.
  • Caveats / not-in-source: the dictionary rebuild assumes the server never returns duplicate SessionId pairs; ToDictionary would throw on a duplicate key. Nothing in this file enforces that, it rests on the server projecting from a dictionary in the first place (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/BookmarkCountsGrpcService.cs:55-59).

BookmarkCountsGrpcService

MMCA.ADC.Engagement.Service · MMCA.ADC.Engagement.Service.Grpc · MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/BookmarkCountsGrpcService.cs:24 · Level 9 · class (public sealed)

  • What it is: the server-side gRPC endpoint that publishes Engagement's in-process IBookmarkCountService over the wire. It is the counterpart of BookmarkCountServiceGrpcAdapter: the adapter runs inside Conference and speaks gRPC out, this service runs inside Engagement and answers.
  • Depends on: IBookmarkCountService (injected via primary constructor, :24), and the generated proto base BookmarkCountService.BookmarkCountServiceBase plus the four messages from bookmark_count.proto (namespace MMCA.ADC.Engagement.Contracts.V1, :2). Externals: Grpc.Core (ServerCallContext).
  • Concept, the server-side bridge. [Rubric §7, Microservices Readiness], [Rubric §9, API & Contract Design]. This is the producer half of the pattern whose consumer half is taught in Group 13: the module keeps one C# interface, the proto generates an abstract server base, and this thin subclass forwards one to the other. Because the interface did not change when Engagement moved out of the monolith, neither Conference's call sites nor Engagement's BookmarkCountService had to change; only this bridge and its client twin were added. It mirrors, on the Engagement side, Conference's SessionBookmarksGrpcService, completing the bidirectional Conference-Engagement gRPC pair.
  • Concept, a documented unauthenticated trust boundary. [Rubric §11, Security] assesses whether every exposed surface has a stated authorization posture. This endpoint is mapped without .RequireAuthorization() (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:334, with the reasoning restated at the call site, :326-332), and the class doc (:11-22) argues why the requirement is not addable rather than merely absent: the Conference callers serve [AllowAnonymous] output-cached REST endpoints (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:441 and :460), so a cache-miss request from an anonymous visitor carries no bearer for JwtForwardingClientInterceptor to forward, and the public speaker pages would break. The exposure is bounded by the payload (aggregate counts per session id, no PII and no user ids on the wire) and by the network boundary: the Engagement container app's ingress is external: false (MMCA.ADC/infra/main.bicep:1351), so the endpoint stays on the internal service network and the Gateway never routes it. Contrast UserEngagementExportGrpcService, which is mapped .RequireAuthorization() (Program.cs:342) because it carries personal data.
  • Walkthrough
    • GetBookmarkCountForSession(request, context) (:28-40): null-guards both arguments with ArgumentNullException.ThrowIfNull (:32-33), awaits inner.GetBookmarkCountForSessionAsync(request.SessionId, context.CancellationToken) (:35-37), threading the gRPC call's own cancellation token (and therefore the client's deadline) through to the database, then wraps the int in a response message (:39).
    • GetBookmarkCountsForSessions(request, context) (:43-61): the batch rpc. It materializes the repeated field with a collection expression, [.. request.SessionIds] (:51), calls the interface's batch method, then projects the returned dictionary into repeated SessionBookmarkCount messages via AddRange(... Select(...)) (:55-59).
  • Why it's built this way: keeping the wire endpoint a paper-thin forwarder means the real read logic (a COUNT under the soft-delete filter, over the SessionId index UserSessionBookmarkConfiguration declares) lives once and is reused identically in-process and over gRPC. The endpoint is published through AddGrpcServiceDefaults() (Program.cs:298), which also installs the GrpcResultExceptionInterceptor and gRPC reflection; for these methods the interceptor has nothing to translate, since the interface returns bare values rather than a Result.
  • Where it's used: mapped at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:334, served over the service's Http2-only cleartext (h2c) endpoint (ADR-012). Its remote caller is Conference.
  • Caveats / not-in-source: the class doc's own citations have drifted. It names SpeakersController.cs:386 and :405 (:15-16) and infra/main.bicep:1289-1295 (:20), while the current anonymous bookmark-count actions are at SpeakersController.cs:441 and :460 and the Engagement internal ingress is at infra/main.bicep:1351. The argument still holds; only the line numbers are stale.

LivePollConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/LivePolls/LivePollConfiguration.cs:16 · Level 9 · class (internal sealed)

  • What it is: the EF Core mapping for the LivePoll aggregate root: column requirements, the question length limit, the two query indexes, and the access mode EF uses to materialize the encapsulated options collection.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> (the shared base that maps audit fields, soft-delete and RowVersion), LivePoll, and LivePollInvariants for QuestionMaxLength. Externals: EF Core's EntityTypeBuilder<T> and PropertyAccessMode.
  • Concept introduced, the no-cross-database foreign key rule. The base class itself is taught in Group 07; what this file makes concrete is the remark at LivePollConfiguration.cs:11-15: EventId and SessionId point at Conference-owned rows in a different database, so they stay plain indexed scalar columns and consistency flows through the Conference gRPC validation boundary, never through an FK constraint. [Rubric §7, Microservices Readiness] assesses schema independence between services, which is exactly what the scalar-reference choice buys. [Rubric §8, Data Architecture] assesses the persistence contract (nullability, lengths, indexes) and the database-per-service discipline (ADR-006); all of it is visible in a dozen lines here, and it is the same rule AttendeeBadgeConfiguration states for UserId.
  • Concept introduced, pinning EF's access mode for an encapsulated collection. [Rubric §4, DDD] assesses whether the aggregate keeps its collections closed to outside mutation. LivePoll exposes Options as a getter over a private List returned through AsReadOnly(), which means EF must read and write the backing field, never the property, or materialization silently produces nothing. EF's convention already infers field access for this shape; :49-50 states it anyway with builder.Navigation(p => p.Options).UsePropertyAccessMode(PropertyAccessMode.Field). The comment at :43-48 gives the reason: stating it makes the mapping independent of that inference, so a later change to the property (a different projection, a computed wrapper) cannot silently turn materialization into a no-op. [Rubric §15, Best Practices & Code Quality] assesses exactly this kind of fail-loudly-later choice. It is an access mode only, no schema change: the relationship itself stays configured from the child side in LivePollOptionConfiguration.
  • Walkthrough (LivePollConfiguration.cs:20-51): Configure (:20) calls base.Configure(builder) first (:22) so the common conventions land before any override; EventId required (:24-25); Question required with HasMaxLength(LivePollInvariants.QuestionMaxLength) (:27-29), sourcing the length from the domain constant so schema and invariant can never disagree; Status required (:31-32). Then HasIndex(p => p.EventId) (:35), non-unique, because the Happening Now page and the organizer manage view both query polls by event (:34); and HasIndex(p => new { p.SessionId, p.Status }) (:41), the composite added for GetOpenPollsHandler. Its comment (:37-40) is the performance record: that query filters on (SessionId, Status), runs once per attendee per session and again on every structural poll event, and only EventId was indexed, so it scanned. It deliberately mirrors SessionQuestionConfiguration, which already indexes the same pair. [Rubric §12, Performance & Scalability] assesses whether indexes match the real query shapes of the hot path. The file closes with the Navigation(...).UsePropertyAccessMode(PropertyAccessMode.Field) call described above (:49-50).
  • Why it's built this way: one length constant sourced from the domain, scalar cross-service references per ADR-006, indexes derived from the two live-layer read shapes rather than guessed, and an explicitly stated access mode so the aggregate's encapsulation and EF's materialization cannot drift apart.
  • Where it's used: discovered and applied at model-build time by the Engagement SQLServerDbContext through EF Core's configuration scanning; it is internal, so nothing else can reach it. Covered by EngagementEntityConfigurationTests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure.Tests/Persistence/EngagementEntityConfigurationTests.cs).

LivePollOptionConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/LivePolls/LivePollOptionConfiguration.cs:10 · Level 9 · class (internal sealed)

  • What it is: the EF Core mapping for the LivePollOption child entity: its text limit and its real relationship back to LivePoll.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, LivePollOption, and LivePollInvariants for OptionTextMaxLength. Externals: EF Core's EntityTypeBuilder<T>.
  • Concept reinforced, the in-aggregate child. Read this directly against LivePollConfiguration: there, a cross-service reference stays a bare scalar; here, both ends are Engagement-owned and in the same database, so the option gets a genuine navigation and a genuine foreign key (LivePollOptionConfiguration.cs:22-25). [Rubric §8, Data Architecture]: the contrast is the lesson, an FK is correct precisely when the constraint can be enforced by one database.
  • Walkthrough (LivePollOptionConfiguration.cs:14-26): Configure (:14) calls base.Configure (:16), then makes Text required with HasMaxLength(LivePollInvariants.OptionTextMaxLength) (:18-20); HasOne(o => o.LivePoll).WithMany(p => p.Options).HasForeignKey(o => o.LivePollId).IsRequired() (:22-25) is the required one-poll-to-many-options relationship inside the aggregate boundary, configured from the child side, which is why LivePollConfiguration only has to state the collection's access mode.
  • Why it's built this way: an option has no meaning without its poll, so the database is allowed to say so; the length again comes from the domain constant rather than a repeated literal.
  • Where it's used: applied by the Engagement SQLServerDbContext at model build; the collection it maps is rehydrated on query paths by LivePollNavigationPopulator and eager-loaded on the delete path by DeleteLivePollHandler. Covered by EngagementEntityConfigurationTests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure.Tests/Persistence/EngagementEntityConfigurationTests.cs).

SetLeaderboardParticipationHandler

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.UseCases.SetLeaderboardParticipation · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/SetLeaderboardParticipation/SetLeaderboardParticipationHandler.cs:31 · Level 9 · class (public sealed partial)

  • What it is: the command handler behind "put me on the leaderboard" and "take me off". One boolean in, a Result out, with all the interesting work in how it resolves the published name and how it reconciles with a soft-deleted prior opt-in.
  • Depends on: IUnitOfWork, ICurrentUserService and its CurrentUserServiceExtensions.RequireUserId member (MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:35), IUniqueConstraintViolationDetector, LeaderboardOptIn, SetLeaderboardParticipationRequest, and the framework contracts ICommandHandler<in TCommand, TResult>, Result and Error. Externals: System.Security.Claims, Microsoft.Extensions.Logging and its [LoggerMessage] source generator (which is why the class is partial, :196-197).
  • Concept, server-side identity for a public surface. [Rubric §11, Security] assesses whether trust decisions read from the token rather than the request body. SetLeaderboardParticipationRequest carries a single bool Participate and nothing else; the display name comes from the caller's claims (:64, :154-185). The class doc (:14-19) states the threat directly: a name in the body would let any caller publish a name they do not own, on a surface whose whole purpose is showing names to other people.
  • Concept, create-or-reactivate against a filtered unique index. [Rubric §8, Data Architecture], [Rubric §4, DDD]. Because the unique index declared in LeaderboardOptInConfiguration is filtered on the soft-delete flag, a soft-deleted opt-in still occupies the UserId slot for a filtered read but not for the constraint. JoinAsync therefore reads through FindIncludingDeletedAsync, which returns the active and the deleted matches as a tuple (:71-74), and the comment (:68-70) states the consequence of not doing so: every rejoin would insert a fresh row and collide with the index. State transitions stay in the aggregate (deleted.Reactivate(displayName) at :86, LeaderboardOptIn.Create(...) at :92), each returning a Result the handler propagates.
  • Concept, classifying the lost insert race through an injected detector. [Rubric §1, SOLID], [Rubric §29, Resilience & Business Continuity]. The save is wrapped in a try whose exception filter is when (uniqueConstraintViolationDetector.IsUniqueConstraintViolation(exception)) (:103). The detector is an injected abstraction (IUniqueConstraintViolationDetector) rather than an inline message match, so the provider-specific knowledge of what a duplicate-key failure looks like lives once in the infrastructure layer (SqlServerUniqueConstraintViolationDetector) and this handler stays provider-agnostic. PointsAwarder leans on the same collaborator (PointsAwarder.cs:75).
  • Walkthrough
    • HandleAsync(command, cancellationToken) (:37-53): null-guards the command (:41), calls currentUserService.RequireUserId("Points.Forbidden") and returns its failure when there is no caller (:43-45), resolves the typed repository (:48), then dispatches on command.Participate to JoinAsync or LeaveAsync (:50-52).
    • JoinAsync (:59-112): resolve the name first and bail on failure (:64-66); read both the active and the soft-deleted opt-ins for the user, tracked (:71-74); if any is active, return success without writing (:78-79), because re-publishing the name on every call would let a name change ride in on a no-op request (:76-77); otherwise reactivate the soft-deleted row (:84-89) or create the first one (:90-97); then save inside the detector-filtered try (:99-109), which converts a lost insert race into the same success the already-on-board path returns, logging at Debug (:108). The comment (:105-107) frames it as intent satisfaction: being on the board is what was asked for.
    • LeaveAsync (:118-136): note the parameter type, IEntityQuerier<TEntity, TIdentifierType> rather than the full IRepository<TEntity, TIdentifierType> (:119), because this branch only reads and then mutates the aggregate it already holds. A filtered, tracked read (:123-126), success without writing when there is nothing active (:127-128), otherwise active.Delete() (:130) and save (:134). Soft-delete, not erasure, which is what leaves the row available for the next reactivate.
    • ResolveDisplayName(user) (:154-185): tries ClaimTypes.Name, then the raw name, then unique_name (:156-158), because inbound claim mapping is not uniform across the hosts that serve this module (:142-145). An empty result is an Error.Validation("Points.DisplayNameUnavailable", ...) (:163-168) rather than a placeholder, since "Attendee" or an email fragment would be meaningless or would leak something the attendee never chose to publish (:146-150). Over-long names are cut at LeaderboardOptIn.DisplayNameMaxLength (:170-184), backing off one index when the cut would split a surrogate pair (:179-182); the comment (:175-177) names the visible symptom it prevents, a U+FFFD in the public leaderboard broadcast.
    • FindClaimValue(user, claimType) (:193-194): a null-tolerant one-liner over ClaimsPrincipal.
  • Why it's built this way: every branch that finds the world already in the requested state returns Result.Success() without writing, so the command is idempotent from the client's point of view and safe to retry. [Rubric §6, CQRS & Event-Driven]. Note what the handler does not do: it carries no transactional marker, so the CQRS decorator pipeline opens no transaction around it, which is consistent with each branch performing at most one save.
  • Where it's used: injected into PointsController as ICommandHandler<SetLeaderboardParticipationRequest, Result> (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:39, action at :103-104); reached from the UI through PointsService.SetLeaderboardParticipationAsync (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/Points/PointsService.cs:59) off the MyPoints page (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Points/MyPoints.razor.cs:117). Covered by SetLeaderboardParticipationHandlerTests.
  • Caveats / not-in-source: the display-name cap is applied by truncation, not rejection, so a caller with a very long token name silently publishes a shortened one; nothing surfaces that to the user. Whether the resulting name is unique on the board is not enforced anywhere: the unique index is on UserId, not on DisplayName, so two attendees with the same token name appear identically.

CheckInConfiguration

MMCA.ADC.Engagement.Infrastructure · MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration.CheckIns · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/CheckIns/CheckInConfiguration.cs:19 · Level 11 · class (internal sealed)

  • What it is: the EF Core mapping for CheckIn, the one aggregate that carries all three check-in scopes (event arrival, session attendance, sponsor booth visit). It is the most constraint-dense configuration in the module: three scope-partitioned unique indexes plus two read indexes.
  • Depends on: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, CheckIn, CheckInScope (by value, not by reference, see below), and IndexBuilderExtensions. Externals: EF Core's EntityTypeBuilder<T>.
  • Concept introduced, partitioning one table by a discriminator through filtered indexes. [Rubric §8, Data Architecture], [Rubric §4, DDD]. One aggregate covers every scope because the row shape, the idempotency rule and the attendance query are identical and only the required target differs (CheckInScope documents this, and CheckInInvariants enforces it). The cost is that a single unique index cannot express "one per attendee per event, and separately one per attendee per session": the three uniqueness rules must not collide with each other. Each index therefore carries its own scope predicate through HasSoftDeleteFilter(additionalFilter: ...) (CheckInConfiguration.cs:51, :56, :62), and the extension ANDs that predicate in front of the soft-delete one (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:61-63). The three scope literals are hoisted into private const string fields (:24-26) with a comment explaining why they are literals at all (:22-23): an index filter is SQL, so it cannot read the C# enum. That is also why the enum's numeric values are documented as load-bearing and never renumbered.
  • Concept, the database as the concurrency backstop behind an application-level check. [Rubric §29, Resilience & Business Continuity], [Rubric §6, CQRS & Event-Driven]. The remarks (:11-18) describe the two layers together: CheckInProcessor makes a repeat scan a no-op by looking for the existing row first (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:71-75), and these indexes make a concurrent double scan a DbUpdateException instead of two rows, so the broker retry that follows lands on the no-op path.
  • Walkthrough (CheckInConfiguration.cs:29-70): base.Configure(builder) (:31); five required properties, UserId (:33-34), Scope (:36-37), EventId (:39-40), CheckedInByUserId (:42-43), CheckedInOn (:45-46), note that SessionId and SponsorId are deliberately not required, since each is meaningful only in its own scope; then the three filtered unique indexes, (UserId, EventId) under the event scope (:49-51), (UserId, SessionId) under the session scope (:54-56), and (UserId, SponsorId) under the sponsor scope (:60-62), the last being the once-per-sponsor cap that makes a shared deep link worth nothing beyond the first scan (:58-59); finally two non-unique, soft-delete-filtered read indexes on EventId (:65-66) and SessionId (:68-69), because attendance stats read by event and group by session (:64).
  • Why it's built this way: sponsor visits are self-recorded by the attendee from a printed QR, so the cap has to be structural rather than procedural. Keeping the identifiers scalar (:16-17) is the same database-per-service rule the rest of the module follows (ADR-006).
  • Where it's used: applied to the Engagement model by assembly scanning. Its indexes are the contract every check-in writer is written against: all four handlers route through CheckInProcessor, the organizer paths CheckInAttendeeHandler (CheckInAttendeeHandler.cs:60) and ManualCheckInHandler (ManualCheckInHandler.cs:40) through ExecuteAsync (CheckInProcessor.cs:109), and the self-service paths RecordRoomCheckInHandler (RecordRoomCheckInHandler.cs:74) and RecordSponsorVisitHandler (RecordSponsorVisitHandler.cs:72) through RecordAsync (CheckInProcessor.cs:58). The rows it maps are exported by UserEngagementExportService and travel to Identity through UserEngagementExportGrpcService.

UserEngagementExportGrpcService

MMCA.ADC.Engagement.Service · MMCA.ADC.Engagement.Service.Grpc · MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/UserEngagementExportGrpcService.cs:23 · Level 12 · class (public sealed)

  • What it is: the server-side gRPC endpoint that publishes Engagement's in-process IUserEngagementExportService to the Identity service, so Identity can fold a user's Engagement-owned personal data into the aggregated data-subject export document. Same server-bridge shape as BookmarkCountsGrpcService, with the extra concern that timestamps and enums have to survive a proto3 wire crossing.
  • Depends on: IUserEngagementExportService (primary constructor, :23) and its DTOs from MMCA.ADC.Engagement.Shared.Exports (:4); the generated base UserEngagementExportService.UserEngagementExportServiceBase and the messages from user_engagement_export.proto (namespace MMCA.ADC.Engagement.Contracts.V1, :3). Externals: Grpc.Core (ServerCallContext), System.Globalization (:1).
  • Concept, cross-service data-subject export as a purpose-built read contract. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether a real data-subject-access and portability path exists; [Rubric §7, Microservices Readiness] assesses talking through explicit contracts. Identity owns the export document but not Engagement's data, so it depends only on the narrow interface; this class is the producer half over the wire, twin to the client-side UserEngagementExportServiceGrpcAdapter. The class doc (:9-15) enumerates what Engagement contributes: session bookmarks, submitted session questions, the points ledger, leaderboard participation, and check-in history. Unlike the bookmark-count endpoint, this one is mapped .RequireAuthorization() (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:342, with the reasoning at :338-340), which is the posture difference personal data justifies. [Rubric §11, Security].
  • Concept, proto3 has no nulls and no DateTime. [Rubric §9, API & Contract Design]. Three encoding decisions are made here and mirrored exactly on the client:
    • Timestamps: SQL Server hands back DateTimeKind.Unspecified values, and the round-trip "O" format omits the Z marker for that kind, so each value is stamped DateTime.SpecifyKind(..., DateTimeKind.Utc) before formatting (:49, :55, :64). The doc comment (:16-21) is careful about what this is: the stored values are already UTC, so this restores a marker rather than converting an instant. The one DateTimeOffset on the contract, CheckedInOn, already carries its offset and is formatted as-is (:78-80).
    • Absent values: an absent leaderboard name travels as the empty string (:42-44) and an absent SessionId/SponsorId as 0 (:73-76), each with the comment naming the client-side inverse.
    • Enums: ActivityType (:59-61) and Scope (:68-70) cross as their numeric values, justified in place, the points activity numbers are part of the idempotency key and the scope numbers are named literally in the SQL index filters (CheckInConfiguration, CheckInConfiguration.cs:24-26), so neither is ever renumbered.
  • Walkthrough: one overridden method, GetUserEngagementExport(request, context) (:27-84). It null-guards both arguments (:31-32), awaits inner.GetUserEngagementExportAsync(request.UserId, context.CancellationToken) (:34-36), then builds the response: the two scalar leaderboard fields in the initializer (:38-45), then four AddRange(... Select(...)) projections filling the repeated fields, bookmarks (:46-50), submitted questions (:51-56), points entries (:57-65) and check-ins (:66-81).
  • Why it's built this way: keeping the endpoint a thin forwarder leaves the real work (server-side projections that the soft-delete query filter already narrows to active rows) in UserEngagementExportService, reused identically in-process and over gRPC. Published via AddGrpcServiceDefaults() (Program.cs:298); because the read returns a DTO rather than a Result, the GrpcResultExceptionInterceptor has no failure trailer to translate and a transport fault becomes a gRPC status the caller's resilience pipeline handles.
  • Where it's used: mapped at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:342. Its remote caller is Identity's ExportUserDataHandler via UserEngagementExportServiceGrpcAdapter. Covered by UserEngagementExportGrpcServiceTests.

UserEngagementExportServiceGrpcAdapter

MMCA.ADC.Engagement.Contracts · MMCA.ADC.Engagement.Contracts · MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:18 · Level 12 · class (internal sealed)

  • What it is: the client-side gRPC adapter for the data-subject export, the remote twin of the in-process UserEngagementExportService. It implements IUserEngagementExportService on top of the generated export client and is what Identity resolves.
  • Depends on: IUserEngagementExportService and its DTOs from MMCA.ADC.Engagement.Shared.Exports, plus CheckInScope and PointsActivityType from the module's Shared project (:3-5); the generated UserEngagementExportService.UserEngagementExportServiceClient and its messages (:2). Externals: System.Globalization (:1), the Grpc.Net client factory.
  • Concept, the same-interface transport swap plus wire rehydration. [Rubric §7, Microservices Readiness], [Rubric §9, API & Contract Design]. This is the export sibling of BookmarkCountServiceGrpcAdapter: the same interface as the in-process implementation, so consumers swap one DI registration (ADR-007). It also owns the inverse of every encoding decision the server made, and each inverse is written next to the decision it undoes.
  • Concept, parsing defensively across a rolling deploy. [Rubric §29, Resilience & Business Continuity]. The comment on ParseRoundtripUtc (:84-90) is the most instructive block in the file. It parses with DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal (:91-95) so that a peer replica still emitting the marker-less form (new Identity code running against an old Engagement replica during a rolling deploy) does not yield Kind=Unspecified, and AssumeUniversal alone would yield Kind=Local. It also records why RoundtripKind is deliberately absent: DateTime.Parse rejects it alongside either Assume* or AdjustToUniversal with an ArgumentException, and its purpose (preserving a non-UTC kind) is the opposite of what this contract wants. ParseRoundtripOffset (:100-104) keeps the offset the server emitted rather than folding it into UTC, with AssumeUniversal covering an offset-less value from an old peer (:97-99).
  • Walkthrough
    • CallDeadline (:24), a 5-second static, applied at :36. The comment (:21-23) gives the same reasoning as the bookmark adapter with an export-specific twist: the aggregation is best-effort per section, so a hung peer should degrade its section quickly rather than stall the export request.
    • GetUserEngagementExportAsync(userId, cancellationToken) (:27-82): sends a GetUserEngagementExportRequest { UserId = userId } with the deadline and the caller's token (:31-37), then rebuilds UserEngagementExportDTO from the four repeated fields with collection expressions: bookmarks (:41-45), submitted questions (:46-51), points entries (:52-60, casting the numeric ActivityType back to its enum at :56), and check-ins (:61-72, casting Scope back at :65, mapping a 0 SessionId/SponsorId back to null at :69-70, and parsing CheckedInOn as a DateTimeOffset at :71). Finally IsOnLeaderboard (:73) and the empty-string-to-null mapping for LeaderboardDisplayName (:78-80), whose comment (:75-77) notes the same mapping also covers an old peer that never sets the field at all.
  • Why it's built this way: the class doc (:9-17) states the resilience posture, transport failures propagate to the caller (Identity's ExportUserDataHandler), which degrades the Engagement section to Available = false rather than failing the whole export. Best-effort cross-service aggregation: a peer outage never sinks an entire data-subject export. [Rubric §30, Compliance, Privacy & Data Governance].
  • Where it's used: registered by DependencyInjection.AddEngagementUserExportClient(), called from MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:290.

DependencyInjection

MMCA.ADC.Engagement.Contracts · MMCA.ADC.Engagement.Contracts · MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:16 · Level 13 · class (public static)

  • What it is: the Engagement *.Contracts DI facade (one of several DependencyInjection classes in this module; this is the extraction-wiring one). Two C# extension(IServiceCollection) members a consuming host calls to swap Engagement's in-process cross-module registrations for their gRPC-backed adapters: bookmark counts for Conference, the data-subject export for Identity.

  • Depends on: the generated BookmarkCountServiceClient / UserEngagementExportServiceClient, BookmarkCountServiceGrpcAdapter, UserEngagementExportServiceGrpcAdapter, IBookmarkCountService, IUserEngagementExportService, and AddTypedGrpcClient<T> from MMCA.Common.Grpc (:6, defined at MMCA.Common/Source/Presentation/MMCA.Common.Grpc/DependencyInjection.cs:87). Externals: Microsoft.Extensions.DependencyInjection and its ServiceCollectionDescriptorExtensions.Replace (:2).

  • Concept, the adapter-swap wiring lives in the contracts package. [Rubric §7, Microservices Readiness], [Rubric §17, DevOps & Deployment], [Rubric §15, Best Practices & Code Quality]. ADR-007 requires each extracted service to publish a *.Contracts project holding the proto definitions and the DI wiring, so a consumer depends on that thin package rather than on the full module. The C# extension(IServiceCollection services) block (:18) adds the methods directly onto the collection (see primer §4).

  • Concept, Replace rather than TryAdd. [Rubric §2, Design Patterns]. Both methods do exactly two things: register a typed client, then replace one interface binding.

    Method File:Line What it swaps
    AddEngagementBookmarkCountClient(serviceName = "engagement") DependencyInjection.cs:43-52 registers BookmarkCountServiceClient (:45), then services.Replace(ServiceDescriptor.Scoped<IBookmarkCountService, BookmarkCountServiceGrpcAdapter>()) (:49)
    AddEngagementUserExportClient(serviceName = "engagement") DependencyInjection.cs:76-85 registers UserEngagementExportServiceClient (:78), then services.Replace(ServiceDescriptor.Scoped<IUserEngagementExportService, UserEngagementExportServiceGrpcAdapter>()) (:82)

    At the moment of the call the container already holds some binding for the interface: either the real in-process implementation (when the Engagement module is enabled in that host) or the disabled stub (DisabledBookmarkCountService / DisabledUserEngagementExportService, registered by EngagementModule.RegisterDisabledStubs, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:30-34). TryAdd would silently lose to it; Replace guarantees the gRPC adapter wins either way, which the inline comments state at :47-48 and :80-81.

  • Walkthrough: AddTypedGrpcClient<T> is what makes each method two lines instead of twenty, it wires the client to Aspire service discovery (http://{serviceName}), the JwtForwardingClientInterceptor and the Polly resilience handler, all from MMCA.Common.Grpc (documented at :20-25 and :54-59). Both methods return IServiceCollection for chaining (:51, :84).

  • Why it's built this way: the default serviceName = "engagement" matches the AppHost resource name, so the common call site is argument-free. [Rubric §33, Developer Experience]. The XML docs on both methods (:35-39, :67-72) spell out the one ordering requirement: call them after ModuleLoader.DiscoverAndRegister(...), so the in-process or stub registration is already in the container for Replace to find. That is an ordering constraint the compiler cannot enforce, which is why it is written twice.

  • Where it's used: MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350 calls AddEngagementBookmarkCountClient(); MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:290 calls AddEngagementUserExportClient(). The AppHost documents both edges of the resulting topology, including why the Conference-to-Engagement reverse edge gets no reciprocal WaitFor (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:271-273, against the Engagement-to-Conference edge at :270), and the Identity-to-Engagement edge at :291.

  • Caveats / not-in-source: neither method sets a per-call deadline; the 5-second budgets live in the two adapters themselves (BookmarkCountServiceGrpcAdapter.cs:20, UserEngagementExportServiceGrpcAdapter.cs:24), so a host that registered a different implementation of the same interfaces would not inherit them.

LiveEventListener

MMCA.ADC.Engagement.UI · MMCA.ADC.Engagement.UI.Components · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Components/LiveEventListener.razor.cs:27 · Level 10 · class (public partial component)

  • What it is: the module's invisible layout component. It renders no markup; on the first render of an authenticated circuit it resolves the current event and, while that event is inside its live window, joins the event-wide live channel so a poll opening reaches the attendee on whatever page they happen to be on. The .razor half is only a comment wrapped in CascadingAuthenticationState / AuthorizeView / Authorized (LiveEventListener.razor:4-10); all the behavior lives in the code-behind, which does its own authentication check rather than relying on that wrapper.
  • Depends on: ILiveEventUIService and the LiveEventContext it returns, NotificationHubService (injected as the concrete sealed class, there is no interface in front of it), IToastService and ToastSeverity, LivePollChannel and LivePollOpenedPayload from MMCA.ADC.Engagement.Shared, IBatteryStatusService and IAccessibilityAnnouncer from MMCA.Common.UI.Services.Capabilities, and EngagementRoutePaths. Externals: Blazor's NavigationManager and the cascading Task<AuthenticationState>, IStringLocalizer<LiveEventListener> (injected in the markup, LiveEventListener.razor:2), System.Text.Json, and the [LoggerMessage] source generator (:188-189).
  • Concept, the ambient listener that must never throw. [Rubric §29, Resilience & Business Continuity] assesses whether a non-essential capability degrades instead of taking the user's session down with it. This component is contributed as a layout component and so is live on every page, and no ErrorBoundary sits above it, which the class remarks state directly (:18-25): an exception escaping its render lifecycle tears down the Blazor circuit and forces a full page reload wherever the user happens to be. The first-render pass is consequently guarded end to end: OperationCanceledException is swallowed as the expected disposal signal (:89-92), and everything else is logged and absorbed behind an inline CA1031 suppression whose justification is written into the pragma itself (:93). The remarks also name the motivating failure mode: the API client's shared resilience pipeline surfaces Polly's TimeoutRejectedException / BrokenCircuitException during a backend brownout, and the service layer's deliberately narrow HttpRequestException catch does not cover those.
  • Concept, two concurrent holders of one channel. [Rubric §19, State Management] assesses how a shared per-circuit resource is owned when more than one component holds it at once. This listener joins LivePollChannel.ForEvent(liveEvent.EventId) (:70, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:24-25), and the Happening Now page joins the identical key for the same event. Membership therefore cannot be a set: it is reference-counted per key inside NotificationHubService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:18-21, :186-189, :217-219), so navigating away from Happening Now releases one join and leaves this listener still receiving events on event:{id}.
  • Concept, capability-aware live UX. [Rubric §21, Accessibility] and [Rubric §27, Internationalization]. Every user-visible string is a resource key resolved through the injected localizer (LiveEventListener.razor:2, ADR-027), and each poll-opened toast is mirrored to the platform's assistive channel through IAccessibilityAnnouncer (:160) so a screen-reader user gets the same event a sighted user sees.
  • Walkthrough
    • PayloadJsonOptions (:28): a static JsonSerializerOptions.Web, so payloads are read with the same web (camelCase) conventions the publisher serialized them with.
    • Seven [Inject] properties (:30-36): the live-event service, the hub service, the toast service, NavigationManager, the battery-status and accessibility-announcer capabilities, and the typed logger. The [CascadingParameter] Task<AuthenticationState>? AuthState (:38-39) is nullable because the component can be rendered outside a CascadingAuthenticationState.
    • _subscription and _channelKey (:41-42): the only mutable state, and exactly what disposal needs.
    • OnAfterRenderAsync(firstRender) (:44-101) is the whole setup path, written as a sequence of early exits: not the first render (:46-49), no cascading auth state (:51-54), an unauthenticated principal (:59-62), and no current event or an event outside its live window per liveEvent.IsLiveAt(DateTime.UtcNow) (:64-68, and MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionLive/LiveEventContext.cs:22-23 for the half-open window it tests). Only past those does it compute _channelKey (:70).
    • The energy-saver branch (:75-85): when Battery.IsEnergySaverOn, the auto-join is skipped and an Info toast offers a "join anyway" action whose callback is JoinChannelGuardedAsync (:79-83), then the method returns without joining. The inline comment (:72-74) is explicit that only the lossy live channel is deferred and the core notification connection is untouched (ADR-042 Wave 3). Otherwise it simply awaits JoinChannelAsync() (:87).
    • JoinChannelGuardedAsync() (:107-123): the same two catches again, because the toast action fires outside the render callback that registered it and inherits none of that callback's guarding (:103-106, :77-78). IToastService.ShowAction documents the same rule from the other side: exceptions are the caller's to handle (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IToastService.cs:96-101).
    • JoinChannelAsync() (:125-134): an idempotence guard first (_channelKey is null || _subscription is not null, :127-130) so a toast-triggered join after an auto-join cannot double-subscribe, then HubService.OnChannelEvent(_channelKey, HandleChannelEventAsync) (:132) followed by HubService.JoinChannelAsync(_channelKey) (:133). Both calls are required and in that order: subscribing does not join the server-side group, and joining does not register a handler.
    • HandleChannelEventAsync(eventName, payloadJson) (:136-174): filters to LivePollChannel.PollOpened (the literal poll.opened, LivePollChannel.cs:14) with an ordinal comparison (:138-141), deserializes a LivePollOpenedPayload and returns silently on a JsonException or a null result (:143-156), announces the poll with the question text (:160), then marshals back to the renderer with InvokeAsync to raise the toast (:164-173). That call passes ToastSeverity.Info and requireInteraction: true (:172-173), so the toast is pinned open rather than expiring on the host timer, and it carries an action that navigates to EngagementRoutePaths.HappeningNow, that is /happening-now (:169, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementRoutePaths.cs:11).
    • DisposeAsync() (:177-186): disposes the subscription handle, then leaves the channel when _channelKey was set (:179-183), and calls GC.SuppressFinalize(this) (:185). The two steps are separate because the hub service tracks handlers and reference-counted membership independently.
    • LogLiveChannelSetupFailed (:188-189): a source-generated Error-level log whose message states the degraded outcome ("the live channel stays dormant for this session"). The catch-all is deliberately silent in the UI, since this component renders nothing and has no place to show a message (:97-98). [Rubric §13, Observability].
  • Why it's built this way: the live layer is best-effort by design, so every failure on this path degrades to "no live channel" rather than surfacing to the user, and the authoritative data is always one page load away. The energy-saver deferral and the screen-reader announcement are the two device-capability touch points, both reached through interfaces whose web fallbacks are no-ops, so the same component runs unchanged on Blazor Server, WebAssembly, and the MAUI hybrid head.
  • Where it's used: contributed by EngagementUIModule as its single LayoutComponentTypes entry (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/EngagementUIModule.cs:31) and rendered by the shared MainLayout on every page; regression-tested by LiveEventListenerResilienceTests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.UI.Tests/Components/LiveEventListenerResilienceTests.cs).
  • Caveats / not-in-source: the battery state is read once, on first render (:75), and the component never subscribes to an energy-saver-changed notification, so turning energy saver off later does not auto-join; the toast action is the only way in for that session. The leave call in DisposeAsync carries no try/catch of its own, it relies on the hub service handling its own failures. The localizer L is declared only in the markup (LiveEventListener.razor:2), so it is invisible in the code-behind despite being used throughout it.

CheckInDTOMapper

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.CheckIns.DTOs · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/DTOs/CheckInDTOMapper.cs:12 · Level 11 · class (public sealed partial)

  • What it is: the Mapperly-generated mapper from the CheckIn entity to CheckInDTO, following the module-wide {Entity}DTOMapper convention.
  • Depends on: CheckIn, CheckInDTO, and the framework contract IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>. Externals: Riok.Mapperly.Abstractions ([Mapper], CheckInDTOMapper.cs:11).
  • Concept, source-generated mapping over reflection. [Rubric §12, Performance & Scalability], [Rubric §15, Best Practices & Code Quality]. The [Mapper] attribute plus a partial method declaration (:16) is the whole single-item mapping: Mapperly generates the property-by-property assignment at compile time, so there is no reflection at runtime and a property whose name or type stops matching becomes a build error rather than a silent null. This is the same pattern taught for the rest of the module's mappers (ADR-001).
  • Walkthrough (CheckInDTOMapper.cs:12-23): the class declares partial CheckInDTO MapToDTO(CheckIn entity) (:16), leaving the body to the generator, and hand-writes the collection overload MapToDTOs (:19-23) as a null-guarded [.. entityCollection.Select(MapToDTO)]. The collection form is written by hand because the interface fixes its signature (IReadOnlyCollection<T> in and out) and the guard belongs in code the analyzers can see.
  • Why it's built this way: implementing the framework interface is what makes the mapper discoverable, ScanModuleApplicationServices<TAssemblyMarker>() registers every IEntityDTOMapper implementation as scoped when the module registers (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:88), so no per-mapper DI line exists anywhere. [Rubric §15, Best Practices & Code Quality].
  • Where it's used: nothing in the ADC source injects this mapper or names CheckInDTO today: the check-in write paths return CheckInResultDTO and the read paths return purpose-shaped rollup and export DTOs. The type exists to complete the entity-DTO-mapper triple for the aggregate and is registered by the convention scan regardless.
  • Caveats / not-in-source: CheckInDTO exposes no SponsorId member (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInDTO.cs:8-30) although the entity has one (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:43), so a sponsor-scoped check-in mapped through here would lose its target. Nothing observes that today, given the absence of call sites.

UserEngagementExportService

MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Exports · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Exports/UserEngagementExportService.cs:17 · Level 11 · class (internal sealed)

  • What it is: the in-process implementation of IUserEngagementExportService. It gathers everything the Engagement module holds about one user (bookmarks, submitted questions, points ledger, check-in history, leaderboard participation) into a single UserEngagementExportDTO for the cross-service data-subject export (PRIVACY.md §7).
  • Depends on: IUnitOfWork and the repositories it hands out; the aggregates UserSessionBookmark, SessionQuestion, PointsEntry, CheckIn and LeaderboardOptIn; the contract IUserEngagementExportService plus its DTO family from MMCA.ADC.Engagement.Shared.Exports (UserEngagementExportDTO, UserEngagementBookmarkExportDTO, UserEngagementSubmittedQuestionExportDTO, UserEngagementPointsEntryExportDTO, UserEngagementCheckInExportDTO).
  • Concept, the data-subject export as five server-side projections. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether there is a real, exercised access and portability path for personal data rather than a policy page; this class is the Engagement half of one. [Rubric §12, Performance & Scalability] assesses whether reads are shaped for the database: every collection is fetched with GetProjectedAsync, whose select expression is translated to SQL (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:105-110), so the server returns only the columns the export publishes and no aggregate is ever materialized. [Rubric §7, Microservices Readiness]: Identity owns the export document but not Engagement's data, so it depends only on the interface, satisfied in-process here and over the wire by UserEngagementExportServiceGrpcAdapter. Same shape as BookmarkCountService.
  • Concept, the soft-delete filter is part of the privacy semantics. [Rubric §8, Data Architecture]. None of the five reads passes ignoreQueryFilters, so the EF global soft-delete filter stays in force and only active personal data is exported (UserEngagementExportService.cs:14-15). That is a deliberate definition of "your data", not an oversight: it is why an opt-in the user has since left reports as not on the leaderboard rather than as a historical membership.
  • Walkthrough (UserEngagementExportService.cs:17-83)
    • The primary constructor injects IUnitOfWork (:17), the only collaborator. Each read then resolves its own typed repository from it (:24, :30, :36, :51, :67) rather than injecting IRepository<TEntity, TIdentifierType> directly, which is the framework rule for staying on one unit of work per request.
    • Bookmarks (:24-28): projects to UserEngagementBookmarkExportDTO { SessionId, CreatedOn } filtered to b.UserId == userId.
    • Submitted questions (:30-34): projects to UserEngagementSubmittedQuestionExportDTO { QuestionId = q.Id, SessionId, CreatedOn }, again filtered by user. Only the questions this user authored are read; no other attendee's text ever enters the document.
    • Points ledger (:36-46): projects to UserEngagementPointsEntryExportDTO { ActivityType, Points, SubjectKey, CreatedOn }. Points travels as the value awarded at the time (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementPointsEntryExportDTO.cs:14-15), so a later change to the configured points-per-activity does not retroactively rewrite the subject's history.
    • Check-ins (:51-62): projects to UserEngagementCheckInExportDTO { Scope, EventId, SessionId, SponsorId, CheckedInOn }. The inline comment (:48-50) states why this is a separate read rather than something derived from the ledger, and the code it points at backs it up: PointsAwarder writes nothing when a rule's configured value is <= 0 (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/Services/PointsAwarder.cs:43-51) and returns a clean no-op on a repeat (:55-64), so the ledger is no proxy for the attendance record the data subject is entitled to.
    • Leaderboard participation (:67-71): projects to the bare string DisplayName rather than to a DTO, because the export carries only a boolean plus a name. The comment (:64-66) records the invariant that makes the check safe: a user holds at most one active opt-in, since rejoining reactivates the soft-deleted row (BR-135, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:87) instead of inserting a second one.
    • Assembly (:73-81): one UserEngagementExportDTO built with collection expressions ([.. bookmarks] and friends), IsOnLeaderboard = leaderboardNames.Count > 0 (:79) and LeaderboardDisplayName = leaderboardNames.FirstOrDefault() (:80), which is null when the collection is empty.
    • The five reads are sequential awaits on one unit of work, not a Task.WhenAll: a single EF context is not thread-safe for concurrent queries, so this is the correct shape, and the cost is five round trips on a request that runs once per data-subject request.
  • Why it's built this way: projecting to ids, enums, and dates server-side keeps the export cheap to build and cheap to ship across a service boundary, and it structurally prevents content authored by other users from leaking into one subject's document (UserEngagementExportDTO states that rule explicitly, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/UserEngagementExportDTO.cs:3-8). Returning a plain Task<T> rather than a Result<T> is what lets the aggregating side degrade: there is no business failure to model here, so an unreachable peer surfaces as an exception the caller turns into a missing section (ADR-007).
  • Where it's used: registered by the Engagement Application DI (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:50, TryAddScoped); exposed over the wire by UserEngagementExportGrpcService, which wraps this in-process instance (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Grpc/UserEngagementExportGrpcService.cs:23). The consumer is Identity's EngagementUserDataExportSection (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/EngagementUserDataExportSection.cs:19), which reaches it through UserEngagementExportServiceGrpcAdapter and re-projects the DTO into the Identity export document, rendering ActivityType and Scope as their readable names (EngagementUserDataExportSection.cs:51, :60), behind ExportUserDataHandler. When the module is switched off in a host, DisabledUserEngagementExportService stands in (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:33).
  • Caveats / not-in-source: erasure is not this class's job and is not visible here, it only reads. Whether the export is additionally rate-limited or audited when it runs in production is not determinable from this file.

ModuleApplicationDbContext

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

  • What it is: the Engagement module's EF Core context declaration. It is an abstract class over the framework's ApplicationDbContext that names, as typed DbSet<T> properties, every entity this bounded context owns: the bookmark aggregate, the badge, the check-in, the three live-poll types, the two question types, and the two points types.
  • Depends on: ApplicationDbContext, IEntityConfigurationAssemblyProvider and PhysicalDataSource (all forwarded to the base constructor), plus the ten Engagement entities: UserSessionBookmark, AttendeeBadge, CheckIn, LivePoll, LivePollOption, LivePollVote, SessionQuestion, SessionQuestionUpvote, PointsEntry, LeaderboardOptIn. Externals: EF Core's DbContext, DbSet<T> and DbContextOptions.
  • Concept introduced, the module's entity inventory is a declaration, not the runtime context. [Rubric §8, Data Architecture] assesses whether persistence structure is deliberate rather than accidental, and [Rubric §3, Clean Architecture] assesses where infrastructure concerns are allowed to live (here: only in *.Infrastructure, never in the domain entities). The instructive detail is what this class does not do. Nothing in the repository derives from it: a repo-wide search for the name finds exactly three declarations, this one plus the sibling per-module classes in Conference (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:20) and Identity (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15), with no subclass and no consumer anywhere in MMCA.ADC/Source or MMCA.ADC/Tests. The context that actually runs is Common's sealed SQLServerDbContext, which extends ApplicationDbContext directly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:15-20) and builds its model by scanning assemblies for entity configurations, not by reading DbSet<T> properties: its OnModelCreating calls ApplyConfigurationsForEntitiesInContext(DataSource.SQLServer, modelBuilder) (SQLServerDbContext.cs:85-89), which picks the engine's configuration interface IEntityTypeConfigurationSQLServer<,> and walks assemblyProvider.GetConfigurationAssemblies(), applying every matching configuration whose entity routes to this context's data source (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:690-717). That is why a module ships UserSessionBookmarkConfiguration and its siblings rather than a context of its own, and it is exactly the ADR-006 rule the workspace states as "one sealed context class per engine, never per module".
  • Walkthrough
    • The primary constructor (ModuleApplicationDbContext.cs:19-24) takes DbContextOptions, IServiceProvider, IEntityConfigurationAssemblyProvider and PhysicalDataSource and forwards all four to ApplicationDbContext unchanged (:24). It adds no state and overrides nothing, so audit stamping and domain event capture stay on the framework's SaveChanges interceptors, which the base resolves and registers in OnConfiguring (ApplicationDbContext.cs:256-270), while the soft-delete global filter, the tenant filter, the concurrency tokens, and the outbox and inbox tables stay in the base's OnModelCreating (ApplicationDbContext.cs:331-358).
    • Ten internal DbSet<T> properties, one per owned entity: UserSessionBookmarks (:27), AttendeeBadges (:30), CheckIns (:33), LivePolls (:36), LivePollOptions (:39), LivePollVotes (:42), SessionQuestions (:45), SessionQuestionUpvotes (:48), PointsEntries (:51, annotated in its own doc comment as the append-only points ledger), and LeaderboardOptIns (:54).
    • internal, not public, is the visibility choice: even if a host did derive from this class, the sets would stay invisible outside the Engagement Infrastructure assembly, so no application-layer code could reach past IUnitOfWork and its repositories into raw EF.
    • Read as documentation, the list is the module's data footprint in one screen: one personal-schedule aggregate, one badge, one attendance record, the conference-day live layer, and the points game. Everything UserEngagementExportService has to gather for a data-subject export is on this list.
  • Why it's built this way: database-per-service (ADR-006) gives Engagement its own ADC_Engagement database with its own outbox, so it never races another service for outbox rows, and cross-module references (UserId, SessionId, EventId, SponsorId) stay scalar columns rather than cross-database foreign keys. The base class is written to be instantiated once per physical data source, keyed by DataSourceModelCacheKeyFactory so each database builds a model containing only its own entities (ApplicationDbContext.cs:300-303). Keeping the module rung abstract and empty of behavior means the engine choice lives entirely in the framework: swapping SQL Server for the Cosmos or SQLite context (ADR-018) is a configuration-plus-configuration-base-class change, not a per-module context rewrite.
  • Where it's used: as a type, nowhere. The Engagement database is scaffolded by MMCA.ADC.Migrations.SqlServer.Engagement, whose design-time factory builds a Common SQLServerDbContext and registers the Engagement Infrastructure assembly as a configuration source (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Engagement/DesignTimeSQLServerDbContextFactory.cs:14-17, :45); the project reference to MMCA.ADC.Engagement.Infrastructure in that csproj (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Engagement/MMCA.ADC.Migrations.SqlServer.Engagement.csproj:15) is what puts this file's assembly (and, with it, the entity configurations) in scope.
  • Caveats / not-in-source: the class exists as a declaration only. It compiles, documents the module's entity set, and mirrors the shape of the Conference and Identity module contexts, but no code path instantiates or inherits it today. Whether it is retained deliberately (as the module-owned inventory, and as the rung a future per-engine subclass would use) or is simply vestigial is not determinable from source: neither the file's doc comment nor an ADR says.

⬅ ADC Conference - UIIndexADC Engagement Live Layer (Real-Time Polls & Session Q&A) ➡